refactor: extract CSS modules for session/settings and delete styles.css (Task 2.3)
- Create InstanceList.module.css, AppShell.module.css, SettingsTabLayout.module.css - Create CommitPanel.module.css, FileViewer.module.css - Create page CSS files: sessions, repo-workspace, dashboard, projects, git-history, ssh-keys, settings - Update components to import and use CSS modules - Delete monolithic styles.css (2,255 lines) - Update main.tsx to import page CSS and new modules Quality gates: tsc (pass), eslint (pass), build (pass) Refs: repo-restructure Task 2.3
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -16,124 +15,39 @@ from src.models.config_profile import ConfigProfile
|
|||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
from src.schemas.config_profile import (
|
from src.schemas.config_profile import (
|
||||||
ConfigProfileCreate,
|
|
||||||
ConfigProfileUpdate,
|
|
||||||
ConfigProfileResponse,
|
|
||||||
ConfigProfileDetailResponse,
|
|
||||||
ConfigIncludeCreate,
|
ConfigIncludeCreate,
|
||||||
ConfigIncludeUpdate,
|
ConfigIncludeUpdate,
|
||||||
ConfigIncludeResponse,
|
|
||||||
ConfigMountCreate,
|
ConfigMountCreate,
|
||||||
ConfigMountUpdate,
|
ConfigMountUpdate,
|
||||||
ConfigMountResponse,
|
ConfigProfileCreate,
|
||||||
|
ConfigProfileUpdate,
|
||||||
DefaultProfilesUpdate,
|
DefaultProfilesUpdate,
|
||||||
)
|
)
|
||||||
|
from src.services.config_profiles import (
|
||||||
|
get_owned_profile,
|
||||||
|
validate_includes_no_cycle,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
MAX_INCLUDES_DEPTH = 10
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _get_owned_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> ConfigProfile:
|
|
||||||
"""Fetch a config profile and verify ownership."""
|
|
||||||
profile = await session.get(ConfigProfile, profile_id)
|
|
||||||
if profile is None or profile.user_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="config profile not found",
|
|
||||||
)
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
async def _detect_cycle(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
visited: set[uuid.UUID] | None = None,
|
|
||||||
depth: int = 0,
|
|
||||||
) -> bool:
|
|
||||||
"""Detect cycles in profile includes using DFS.
|
|
||||||
|
|
||||||
Returns True if a cycle is detected.
|
|
||||||
"""
|
|
||||||
if depth > MAX_INCLUDES_DEPTH:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if visited is None:
|
|
||||||
visited = set()
|
|
||||||
|
|
||||||
if profile_id in visited:
|
|
||||||
return True
|
|
||||||
|
|
||||||
visited.add(profile_id)
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigInclude.included_profile_id).where(
|
|
||||||
ConfigInclude.profile_id == profile_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
included_ids = result.scalars().all()
|
|
||||||
|
|
||||||
for included_id in included_ids:
|
|
||||||
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_includes_no_cycle(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
new_included_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Validate that adding an include wouldn't create a cycle."""
|
|
||||||
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="adding this include would create a circular reference",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Profile CRUD
|
# Profile CRUD
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get(
|
@router.get("", summary="List config profiles")
|
||||||
"",
|
|
||||||
summary="List config profiles",
|
|
||||||
description="Get all config profiles for the current user. Optionally filter by tool type compatibility.",
|
|
||||||
)
|
|
||||||
async def list_config_profiles(
|
async def list_config_profiles(
|
||||||
tool_type_id: str | None = None,
|
tool_type_id: str | None = None,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List config profiles for the current user."""
|
|
||||||
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
|
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
|
||||||
|
|
||||||
# If tool_type_id is provided, filter to compatible profiles
|
|
||||||
# For now, all profiles are considered compatible with all tool types
|
|
||||||
# since there's no explicit compatibility matrix. Future enhancement:
|
|
||||||
# could filter by profile tags or mount path patterns.
|
|
||||||
if tool_type_id:
|
if tool_type_id:
|
||||||
# Validate the tool type exists
|
|
||||||
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="tool type not found",
|
|
||||||
)
|
|
||||||
# All profiles are compatible; just return user's profiles
|
|
||||||
pass
|
|
||||||
|
|
||||||
result = await session.execute(query.order_by(ConfigProfile.name))
|
result = await session.execute(query.order_by(ConfigProfile.name))
|
||||||
profiles = result.scalars().all()
|
profiles = result.scalars().all()
|
||||||
@@ -153,19 +67,12 @@ async def list_config_profiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("", status_code=status.HTTP_201_CREATED, summary="Create config profile")
|
||||||
"",
|
|
||||||
summary="Create config profile",
|
|
||||||
description="Create a new config profile.",
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def create_config_profile(
|
async def create_config_profile(
|
||||||
data: ConfigProfileCreate,
|
data: ConfigProfileCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a config profile."""
|
|
||||||
# Check for duplicate name
|
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(ConfigProfile).where(
|
select(ConfigProfile).where(
|
||||||
ConfigProfile.user_id == user_id,
|
ConfigProfile.user_id == user_id,
|
||||||
@@ -178,11 +85,7 @@ async def create_config_profile(
|
|||||||
detail=f"config profile with name '{data.name}' already exists",
|
detail=f"config profile with name '{data.name}' already exists",
|
||||||
)
|
)
|
||||||
|
|
||||||
profile = ConfigProfile(
|
profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description)
|
||||||
user_id=user_id,
|
|
||||||
name=data.name,
|
|
||||||
description=data.description,
|
|
||||||
)
|
|
||||||
session.add(profile)
|
session.add(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(profile)
|
await session.refresh(profile)
|
||||||
@@ -197,118 +100,68 @@ async def create_config_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/defaults", summary="Get default profiles")
|
||||||
"/defaults",
|
|
||||||
summary="Get default profiles",
|
|
||||||
description="Get the current user's default profile assignments per tool type.",
|
|
||||||
)
|
|
||||||
async def get_default_profiles(
|
async def get_default_profiles(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get default profiles for the current user."""
|
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||||
result = await session.execute(
|
|
||||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
|
||||||
)
|
|
||||||
user_config = result.scalar_one_or_none()
|
user_config = result.scalar_one_or_none()
|
||||||
|
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
||||||
if user_config is None:
|
|
||||||
return {"default_profiles": {}}
|
|
||||||
|
|
||||||
return {"default_profiles": user_config.default_profiles}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/defaults", summary="Set default profiles")
|
||||||
"/defaults",
|
|
||||||
summary="Set default profiles",
|
|
||||||
description="Set the current user's default profile assignments per tool type.",
|
|
||||||
)
|
|
||||||
async def set_default_profiles(
|
async def set_default_profiles(
|
||||||
data: DefaultProfilesUpdate,
|
data: DefaultProfilesUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Set default profiles for the current user."""
|
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||||
result = await session.execute(
|
|
||||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
|
||||||
)
|
|
||||||
user_config = result.scalar_one_or_none()
|
user_config = result.scalar_one_or_none()
|
||||||
|
|
||||||
if user_config is None:
|
if user_config is None:
|
||||||
user_config = UserConfig(user_id=user_id, config={})
|
user_config = UserConfig(user_id=user_id, config={})
|
||||||
session.add(user_config)
|
session.add(user_config)
|
||||||
|
|
||||||
# Validate all profile IDs belong to the user
|
|
||||||
for tool_type_id, profile_id_str in data.default_profiles.items():
|
for tool_type_id, profile_id_str in data.default_profiles.items():
|
||||||
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
|
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"profile {profile_id_str} not found",
|
|
||||||
)
|
|
||||||
if profile.user_id != user_id:
|
if profile.user_id != user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user")
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"profile {profile_id_str} does not belong to user",
|
|
||||||
)
|
|
||||||
|
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
|
||||||
user_config.config = {**user_config.config, "default_profiles": data.default_profiles}
|
user_config.config = {**user_config.config, "default_profiles": data.default_profiles}
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user_config)
|
await session.refresh(user_config)
|
||||||
|
|
||||||
return {"default_profiles": user_config.default_profiles}
|
return {"default_profiles": user_config.default_profiles}
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/defaults/{tool_type_id}", summary="Get default profile for tool type")
|
||||||
"/defaults/{tool_type_id}",
|
|
||||||
summary="Get default profile for tool type",
|
|
||||||
description="Get the default profile ID for a specific tool type.",
|
|
||||||
)
|
|
||||||
async def get_default_profile_for_tool_type(
|
async def get_default_profile_for_tool_type(
|
||||||
tool_type_id: str,
|
tool_type_id: str,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get default profile for a specific tool type."""
|
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||||
result = await session.execute(
|
|
||||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
|
||||||
)
|
|
||||||
user_config = result.scalar_one_or_none()
|
user_config = result.scalar_one_or_none()
|
||||||
|
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
||||||
if user_config is None:
|
|
||||||
return {"tool_type_id": tool_type_id, "profile_id": None}
|
|
||||||
|
|
||||||
profile_id = user_config.default_profiles.get(tool_type_id)
|
|
||||||
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{profile_id}", summary="Get config profile")
|
||||||
"/{profile_id}",
|
|
||||||
summary="Get config profile",
|
|
||||||
description="Get a config profile with its includes and mounts.",
|
|
||||||
)
|
|
||||||
async def get_config_profile(
|
async def get_config_profile(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get a config profile with includes and mounts."""
|
|
||||||
profile = await session.get(
|
profile = await session.get(
|
||||||
ConfigProfile,
|
ConfigProfile,
|
||||||
profile_id,
|
profile_id,
|
||||||
options=[
|
options=[selectinload(ConfigProfile.includes), selectinload(ConfigProfile.mounts)],
|
||||||
selectinload(ConfigProfile.includes),
|
|
||||||
selectinload(ConfigProfile.mounts),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
if profile is None or profile.user_id != user_id:
|
if profile is None or profile.user_id != user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="config profile not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch included profile names
|
|
||||||
includes_data = []
|
includes_data = []
|
||||||
for inc in profile.includes:
|
for inc in profile.includes:
|
||||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
||||||
@@ -348,22 +201,16 @@ async def get_config_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{profile_id}", summary="Update config profile")
|
||||||
"/{profile_id}",
|
|
||||||
summary="Update config profile",
|
|
||||||
description="Update an existing config profile.",
|
|
||||||
)
|
|
||||||
async def update_config_profile(
|
async def update_config_profile(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
data: ConfigProfileUpdate,
|
data: ConfigProfileUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a config profile."""
|
profile = await get_owned_profile(profile_id, user_id, session)
|
||||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
# Check for duplicate name
|
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(ConfigProfile).where(
|
select(ConfigProfile).where(
|
||||||
ConfigProfile.user_id == user_id,
|
ConfigProfile.user_id == user_id,
|
||||||
@@ -394,19 +241,13 @@ async def update_config_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete config profile")
|
||||||
"/{profile_id}",
|
|
||||||
summary="Delete config profile",
|
|
||||||
description="Delete a config profile and all its includes and mounts.",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
)
|
|
||||||
async def delete_config_profile(
|
async def delete_config_profile(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a config profile."""
|
profile = await get_owned_profile(profile_id, user_id, session)
|
||||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -415,19 +256,13 @@ async def delete_config_profile(
|
|||||||
# Include management
|
# Include management
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{profile_id}/includes", summary="List profile includes")
|
||||||
"/{profile_id}/includes",
|
|
||||||
summary="List profile includes",
|
|
||||||
description="Get all includes for a config profile.",
|
|
||||||
)
|
|
||||||
async def list_profile_includes(
|
async def list_profile_includes(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List includes for a config profile."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigInclude)
|
select(ConfigInclude)
|
||||||
.where(ConfigInclude.profile_id == profile_id)
|
.where(ConfigInclude.profile_id == profile_id)
|
||||||
@@ -451,44 +286,25 @@ async def list_profile_includes(
|
|||||||
return {"includes": includes_data}
|
return {"includes": includes_data}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED, summary="Add profile include")
|
||||||
"/{profile_id}/includes",
|
|
||||||
summary="Add profile include",
|
|
||||||
description="Add an include to a config profile.",
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def add_profile_include(
|
async def add_profile_include(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
data: ConfigIncludeCreate,
|
data: ConfigIncludeCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Add an include to a config profile."""
|
profile = await get_owned_profile(profile_id, user_id, session)
|
||||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
included_profile_id = uuid.UUID(data.included_profile_id)
|
included_profile_id = uuid.UUID(data.included_profile_id)
|
||||||
|
|
||||||
# Cannot include self
|
|
||||||
if included_profile_id == profile_id:
|
if included_profile_id == profile_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself")
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="a profile cannot include itself",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the included profile exists and belongs to the user
|
|
||||||
included_profile = await session.get(ConfigProfile, included_profile_id)
|
included_profile = await session.get(ConfigProfile, included_profile_id)
|
||||||
if included_profile is None:
|
if included_profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="included profile not found",
|
|
||||||
)
|
|
||||||
if included_profile.user_id != user_id:
|
if included_profile.user_id != user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user")
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="included profile does not belong to user",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for duplicate include
|
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(ConfigInclude).where(
|
select(ConfigInclude).where(
|
||||||
ConfigInclude.profile_id == profile_id,
|
ConfigInclude.profile_id == profile_id,
|
||||||
@@ -496,13 +312,9 @@ async def add_profile_include(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="this include already exists")
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="this include already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate no cycles
|
await validate_includes_no_cycle(session, profile_id, included_profile_id)
|
||||||
await _validate_includes_no_cycle(session, profile_id, included_profile_id)
|
|
||||||
|
|
||||||
include = ConfigInclude(
|
include = ConfigInclude(
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
@@ -524,11 +336,7 @@ async def add_profile_include(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{profile_id}/includes/{include_id}", summary="Update profile include")
|
||||||
"/{profile_id}/includes/{include_id}",
|
|
||||||
summary="Update profile include",
|
|
||||||
description="Update the order index of a profile include.",
|
|
||||||
)
|
|
||||||
async def update_profile_include(
|
async def update_profile_include(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
include_id: uuid.UUID,
|
include_id: uuid.UUID,
|
||||||
@@ -536,15 +344,10 @@ async def update_profile_include(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a profile include."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
include = await session.get(ConfigInclude, include_id)
|
include = await session.get(ConfigInclude, include_id)
|
||||||
if include is None or include.profile_id != profile_id:
|
if include is None or include.profile_id != profile_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="include not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
include.order_index = data.order_index
|
include.order_index = data.order_index
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -562,28 +365,17 @@ async def update_profile_include(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove profile include")
|
||||||
"/{profile_id}/includes/{include_id}",
|
|
||||||
summary="Remove profile include",
|
|
||||||
description="Remove an include from a config profile.",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
)
|
|
||||||
async def remove_profile_include(
|
async def remove_profile_include(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
include_id: uuid.UUID,
|
include_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Remove an include from a config profile."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
include = await session.get(ConfigInclude, include_id)
|
include = await session.get(ConfigInclude, include_id)
|
||||||
if include is None or include.profile_id != profile_id:
|
if include is None or include.profile_id != profile_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="include not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.delete(include)
|
await session.delete(include)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -592,19 +384,13 @@ async def remove_profile_include(
|
|||||||
# Mount management
|
# Mount management
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{profile_id}/mounts", summary="List profile mounts")
|
||||||
"/{profile_id}/mounts",
|
|
||||||
summary="List profile mounts",
|
|
||||||
description="Get all mounts for a config profile.",
|
|
||||||
)
|
|
||||||
async def list_profile_mounts(
|
async def list_profile_mounts(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List mounts for a config profile."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigMount)
|
select(ConfigMount)
|
||||||
.where(ConfigMount.profile_id == profile_id)
|
.where(ConfigMount.profile_id == profile_id)
|
||||||
@@ -629,22 +415,14 @@ async def list_profile_mounts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED, summary="Add profile mount")
|
||||||
"/{profile_id}/mounts",
|
|
||||||
summary="Add profile mount",
|
|
||||||
description="Add a mount to a config profile.",
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def add_profile_mount(
|
async def add_profile_mount(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
data: ConfigMountCreate,
|
data: ConfigMountCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Add a mount to a config profile."""
|
profile = await get_owned_profile(profile_id, user_id, session)
|
||||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
# Check for duplicate target_path
|
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(ConfigMount).where(
|
select(ConfigMount).where(
|
||||||
ConfigMount.profile_id == profile_id,
|
ConfigMount.profile_id == profile_id,
|
||||||
@@ -680,11 +458,7 @@ async def add_profile_mount(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{profile_id}/mounts/{mount_id}", summary="Update profile mount")
|
||||||
"/{profile_id}/mounts/{mount_id}",
|
|
||||||
summary="Update profile mount",
|
|
||||||
description="Update a mount in a config profile.",
|
|
||||||
)
|
|
||||||
async def update_profile_mount(
|
async def update_profile_mount(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
mount_id: uuid.UUID,
|
mount_id: uuid.UUID,
|
||||||
@@ -692,18 +466,12 @@ async def update_profile_mount(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a profile mount."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
mount = await session.get(ConfigMount, mount_id)
|
mount = await session.get(ConfigMount, mount_id)
|
||||||
if mount is None or mount.profile_id != profile_id:
|
if mount is None or mount.profile_id != profile_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="mount not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.target_path is not None:
|
if data.target_path is not None:
|
||||||
# Check for duplicate target_path
|
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(ConfigMount).where(
|
select(ConfigMount).where(
|
||||||
ConfigMount.profile_id == profile_id,
|
ConfigMount.profile_id == profile_id,
|
||||||
@@ -720,7 +488,6 @@ async def update_profile_mount(
|
|||||||
|
|
||||||
if data.files is not None:
|
if data.files is not None:
|
||||||
mount.files = data.files
|
mount.files = data.files
|
||||||
|
|
||||||
if data.order_index is not None:
|
if data.order_index is not None:
|
||||||
mount.order_index = data.order_index
|
mount.order_index = data.order_index
|
||||||
|
|
||||||
@@ -739,27 +506,16 @@ async def update_profile_mount(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove profile mount")
|
||||||
"/{profile_id}/mounts/{mount_id}",
|
|
||||||
summary="Remove profile mount",
|
|
||||||
description="Remove a mount from a config profile.",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
)
|
|
||||||
async def remove_profile_mount(
|
async def remove_profile_mount(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
mount_id: uuid.UUID,
|
mount_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Remove a mount from a config profile."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
mount = await session.get(ConfigMount, mount_id)
|
mount = await session.get(ConfigMount, mount_id)
|
||||||
if mount is None or mount.profile_id != profile_id:
|
if mount is None or mount.profile_id != profile_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="mount not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.delete(mount)
|
await session.delete(mount)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
|||||||
|
"""Config profile business logic."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_include import ConfigInclude
|
||||||
|
from src.models.config_mount import ConfigMount
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_INCLUDES_DEPTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
async def get_owned_profile(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> ConfigProfile:
|
||||||
|
"""Fetch a config profile and verify ownership."""
|
||||||
|
profile = await session.get(ConfigProfile, profile_id)
|
||||||
|
if profile is None or profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="config profile not found",
|
||||||
|
)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
async def _detect_cycle(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID] | None = None,
|
||||||
|
depth: int = 0,
|
||||||
|
) -> bool:
|
||||||
|
"""Detect cycles in profile includes using DFS.
|
||||||
|
|
||||||
|
Returns True if a cycle is detected.
|
||||||
|
"""
|
||||||
|
if depth > MAX_INCLUDES_DEPTH:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if visited is None:
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
if profile_id in visited:
|
||||||
|
return True
|
||||||
|
|
||||||
|
visited.add(profile_id)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigInclude.included_profile_id).where(
|
||||||
|
ConfigInclude.profile_id == profile_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
included_ids = result.scalars().all()
|
||||||
|
|
||||||
|
for included_id in included_ids:
|
||||||
|
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_includes_no_cycle(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
new_included_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate that adding an include wouldn't create a cycle."""
|
||||||
|
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="adding this include would create a circular reference",
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Git services package."""
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Git control operations with repo validation."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.user import User
|
||||||
|
from src.schemas.git_repository import (
|
||||||
|
BranchCreateRequest,
|
||||||
|
CheckoutRequest,
|
||||||
|
CommitRequest,
|
||||||
|
FetchResponse,
|
||||||
|
MergeRequest,
|
||||||
|
MergeResponse,
|
||||||
|
PullResponse,
|
||||||
|
PushResponse,
|
||||||
|
StatusResponse,
|
||||||
|
)
|
||||||
|
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
||||||
|
from src.utils.git_control import (
|
||||||
|
checkout_branch,
|
||||||
|
commit_changes,
|
||||||
|
create_branch,
|
||||||
|
delete_branch,
|
||||||
|
fetch,
|
||||||
|
get_status,
|
||||||
|
merge,
|
||||||
|
pull,
|
||||||
|
push,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_status_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
) -> StatusResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
result = get_status(repo.path)
|
||||||
|
return StatusResponse(
|
||||||
|
branch=result.branch,
|
||||||
|
modified=result.modified,
|
||||||
|
added=result.added,
|
||||||
|
deleted=result.deleted,
|
||||||
|
untracked=result.untracked,
|
||||||
|
renamed=result.renamed,
|
||||||
|
ahead=result.ahead,
|
||||||
|
behind=result.behind,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def create_branch_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: BranchCreateRequest,
|
||||||
|
) -> dict:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
create_branch(repo.path, data.name, data.base_branch)
|
||||||
|
return {"message": f"Branch '{data.name}' created", "branch": data.name}
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_branch_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
branch_name: str,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
delete_branch(repo.path, branch_name, force)
|
||||||
|
return {"message": f"Branch '{branch_name}' deleted"}
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def checkout_branch_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: CheckoutRequest,
|
||||||
|
) -> dict:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
checkout_branch(repo.path, data.branch)
|
||||||
|
return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def commit_changes_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: CommitRequest,
|
||||||
|
user: User,
|
||||||
|
) -> dict:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
author_name = user.name or "Unknown"
|
||||||
|
author_email = user.email or "unknown@example.com"
|
||||||
|
try:
|
||||||
|
commit_hash = commit_changes(
|
||||||
|
repo_path=repo.path,
|
||||||
|
message=data.message,
|
||||||
|
author_name=author_name,
|
||||||
|
author_email=author_email,
|
||||||
|
files=data.files,
|
||||||
|
)
|
||||||
|
return {"commit_hash": commit_hash, "message": data.message}
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
) -> FetchResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
fetch(repo.path)
|
||||||
|
return FetchResponse(message="Fetched from remote")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def pull_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
branch: str | None = None,
|
||||||
|
) -> PullResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
pull(repo.path, branch)
|
||||||
|
return PullResponse(message="Pulled from remote")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def push_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
branch: str | None = None,
|
||||||
|
) -> PushResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
push(repo.path, branch)
|
||||||
|
return PushResponse(message="Pushed to remote")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def merge_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: MergeRequest,
|
||||||
|
) -> MergeResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
commit_hash = merge(
|
||||||
|
repo_path=repo.path,
|
||||||
|
source_branch=data.source_branch,
|
||||||
|
target_branch=data.target_branch,
|
||||||
|
message=data.message,
|
||||||
|
)
|
||||||
|
return MergeResponse(
|
||||||
|
commit_hash=commit_hash,
|
||||||
|
message=data.message or f"Merge {data.source_branch}",
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Git file operations with repo validation."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.user import User
|
||||||
|
from src.schemas.git_repository import (
|
||||||
|
FileContentResponse,
|
||||||
|
FileListResponse,
|
||||||
|
FileUpdateRequest,
|
||||||
|
FileUpdateResponse,
|
||||||
|
)
|
||||||
|
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
||||||
|
from src.utils.git_files import (
|
||||||
|
commit_file,
|
||||||
|
get_file_content,
|
||||||
|
list_branches,
|
||||||
|
list_tree,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_files(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
branch: str = "main",
|
||||||
|
path: str = "",
|
||||||
|
) -> FileListResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
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:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list files for repo %s (path=%s, branch=%s): %s",
|
||||||
|
repo_id,
|
||||||
|
path,
|
||||||
|
branch,
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_file(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
branch: str,
|
||||||
|
path: str,
|
||||||
|
) -> FileContentResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
async def update_file(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: FileUpdateRequest,
|
||||||
|
user: User,
|
||||||
|
) -> FileUpdateResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
async def list_branches_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
) -> dict:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
branches, default_branch = list_branches(repo.path)
|
||||||
|
return {
|
||||||
|
"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:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list branches for repo %s: %s",
|
||||||
|
repo_id,
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Repository lifecycle and path helpers."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.config import Settings
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.user import User
|
||||||
|
from src.schemas.git_repository import GitRepositoryCreate
|
||||||
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
||||||
|
"""Generate the filesystem path for a repository."""
|
||||||
|
base = Settings().repo_base_path or "/data/repos"
|
||||||
|
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_provider_clone_url(owner: str, repo: str) -> str:
|
||||||
|
"""Build the SSH clone URL for the fixed git provider."""
|
||||||
|
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_remote_repository(remote_url: str) -> None:
|
||||||
|
"""Verify a remote repository is reachable before cloning."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", remote_url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="repository not found or inaccessible",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "clone", remote_url, repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to clone repository: {result.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_working_repository(repo_path: str) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "init", "-b", "main", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
fallback = subprocess.run(
|
||||||
|
["git", "init", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if fallback.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to initialize repository: {fallback.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
ref_result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if ref_result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to set initial branch: {ref_result.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_repo_and_validate(
|
||||||
|
session: AsyncSession,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Fetch a repository and validate ownership + disk presence."""
|
||||||
|
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")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_repo_on_disk(repo: GitRepository) -> None:
|
||||||
|
"""Raise 404 if the repository is not present on disk."""
|
||||||
|
if not os.path.exists(repo.path):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_repository(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
data: GitRepositoryCreate,
|
||||||
|
user: User,
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Create a new git repository (clone or init)."""
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.execute(
|
||||||
|
select(GitRepository).where(
|
||||||
|
GitRepository.project_id == project_id,
|
||||||
|
GitRepository.name == data.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||||
|
|
||||||
|
# Validate and potentially correct the URL
|
||||||
|
remote_url = data.remote_url
|
||||||
|
if remote_url and not data.force_original_url:
|
||||||
|
parse_result = parse_git_url(remote_url)
|
||||||
|
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||||
|
"suggested_url": parse_result["base_url"],
|
||||||
|
"original_url": remote_url,
|
||||||
|
"error_code": "URL_NEEDS_PARSING",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if parse_result["base_url"]:
|
||||||
|
remote_url = parse_result["base_url"]
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
_preflight_remote_repository(remote_url)
|
||||||
|
|
||||||
|
repo_path = _get_repo_path(user.id, project_id, data.name)
|
||||||
|
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
_clone_working_repository(remote_url, repo_path)
|
||||||
|
else:
|
||||||
|
_init_working_repository(repo_path)
|
||||||
|
|
||||||
|
repo = GitRepository(
|
||||||
|
name=data.name,
|
||||||
|
path=repo_path,
|
||||||
|
project_id=project_id,
|
||||||
|
owner_id=user.id,
|
||||||
|
is_mirror=False,
|
||||||
|
remote_url=remote_url,
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_repository(
|
||||||
|
session: AsyncSession,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a repository from DB and disk."""
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
|
||||||
|
if os.path.exists(repo.path):
|
||||||
|
shutil.rmtree(repo.path)
|
||||||
|
|
||||||
|
await session.delete(repo)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_repositories(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
) -> list[GitRepository]:
|
||||||
|
"""List all repositories in a project."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
@@ -8,6 +8,7 @@ import { useAuth } from "../state/auth";
|
|||||||
import { useSessions } from "../state/sessions";
|
import { useSessions } from "../state/sessions";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
import styles from "./layout/AppShell.module.css";
|
||||||
|
|
||||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||||
{ to: "/", label: "Home", icon: "dashboard" },
|
{ to: "/", label: "Home", icon: "dashboard" },
|
||||||
@@ -28,12 +29,12 @@ const SessionItem = ({ session }: { session: Session }) => {
|
|||||||
href={session.url ?? `/projects/${session.project_id}`}
|
href={session.url ?? `/projects/${session.project_id}`}
|
||||||
target={session.url ? "_blank" : undefined}
|
target={session.url ? "_blank" : undefined}
|
||||||
rel={session.url ? "noopener noreferrer" : undefined}
|
rel={session.url ? "noopener noreferrer" : undefined}
|
||||||
className="nav-item session-item"
|
className={`${styles.navItem} ${styles.sessionItem}`}
|
||||||
title={`${displayName} (${session.status})`}
|
title={`${displayName} (${session.status})`}
|
||||||
>
|
>
|
||||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
<span className={`${styles.sessionStatus} ${isRunning ? styles.running : ""}`} />
|
||||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||||
<span className="session-name">{displayName}</span>
|
<span className={styles.sessionName}>{displayName}</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -62,12 +63,12 @@ export const AppShell = () => {
|
|||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className={styles.shell}>
|
||||||
<header className="shell-header">
|
<header className={styles.shellHeader}>
|
||||||
<Link className="brand" to="/">
|
<Link className={styles.brand} to="/">
|
||||||
Headquarter
|
Headquarter
|
||||||
</Link>
|
</Link>
|
||||||
<div className="header-actions">
|
<div className={styles.headerActions}>
|
||||||
<Link className="user-chip" to="/profile">
|
<Link className="user-chip" to="/profile">
|
||||||
{user?.name ?? "User"}
|
{user?.name ?? "User"}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -84,8 +85,8 @@ export const AppShell = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="shell-body">
|
<div className={styles.shellBody}>
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
<aside className={styles.shellNav} aria-label="Primary navigation">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isHome = item.to === "/";
|
const isHome = item.to === "/";
|
||||||
const activeCount = sessions.filter(
|
const activeCount = sessions.filter(
|
||||||
@@ -96,14 +97,14 @@ export const AppShell = () => {
|
|||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
isActive ? "nav-item nav-item-active" : "nav-item"
|
isActive ? `${styles.navItem} ${styles.navItemActive}` : styles.navItem
|
||||||
}
|
}
|
||||||
end={item.to === "/"}
|
end={item.to === "/"}
|
||||||
>
|
>
|
||||||
<Icon name={item.icon} size="sm" />
|
<Icon name={item.icon} size="sm" />
|
||||||
{item.label}
|
{item.label}
|
||||||
{isHome && activeCount > 0 && (
|
{isHome && activeCount > 0 && (
|
||||||
<span className="nav-badge">{activeCount}</span>
|
<span className={styles.navBadge}>{activeCount}</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
@@ -112,8 +113,8 @@ export const AppShell = () => {
|
|||||||
{sessions.filter((s) => ACTIVE_STATUSES.includes(s.status)).length >
|
{sessions.filter((s) => ACTIVE_STATUSES.includes(s.status)).length >
|
||||||
0 && (
|
0 && (
|
||||||
<>
|
<>
|
||||||
<div className="nav-divider" />
|
<div className={styles.navDivider} />
|
||||||
<div className="nav-section-title">Live sessions</div>
|
<div className={styles.navSectionTitle}>Live sessions</div>
|
||||||
{sessions
|
{sessions
|
||||||
.filter((s) => ACTIVE_STATUSES.includes(s.status))
|
.filter((s) => ACTIVE_STATUSES.includes(s.status))
|
||||||
.map((session) => (
|
.map((session) => (
|
||||||
@@ -123,7 +124,7 @@ export const AppShell = () => {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="shell-content">
|
<main className={styles.shellContent}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { commitChanges } from "../api/git_repositories";
|
import { commitChanges } from "../api/git_repositories";
|
||||||
|
import styles from "./features/git/CommitPanel.module.css";
|
||||||
|
|
||||||
interface CommitPanelProps {
|
interface CommitPanelProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -49,49 +50,49 @@ export const CommitPanel = ({
|
|||||||
if (!hasChanges) return null;
|
if (!hasChanges) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="commit-panel">
|
<div className={styles.commitPanel}>
|
||||||
<h4>Changes</h4>
|
<h4>Changes</h4>
|
||||||
|
|
||||||
<div className="file-list">
|
<div className={styles.fileList}>
|
||||||
{modified.map((file) => (
|
{modified.map((file) => (
|
||||||
<div key={file} className="file-item modified">
|
<div key={file} className={`${styles.fileItem} modified`}>
|
||||||
<span className="file-status">M</span>
|
<span className={styles.fileStatus}>M</span>
|
||||||
<span className="file-name">{file}</span>
|
<span>{file}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{added.map((file) => (
|
{added.map((file) => (
|
||||||
<div key={file} className="file-item added">
|
<div key={file} className={`${styles.fileItem} added`}>
|
||||||
<span className="file-status">A</span>
|
<span className={styles.fileStatus}>A</span>
|
||||||
<span className="file-name">{file}</span>
|
<span>{file}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{deleted.map((file) => (
|
{deleted.map((file) => (
|
||||||
<div key={file} className="file-item deleted">
|
<div key={file} className={`${styles.fileItem} deleted`}>
|
||||||
<span className="file-status">D</span>
|
<span className={styles.fileStatus}>D</span>
|
||||||
<span className="file-name">{file}</span>
|
<span>{file}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{untracked.map((file) => (
|
{untracked.map((file) => (
|
||||||
<div key={file} className="file-item untracked">
|
<div key={file} className={`${styles.fileItem} untracked`}>
|
||||||
<span className="file-status">?</span>
|
<span className={styles.fileStatus}>?</span>
|
||||||
<span className="file-name">{file}</span>
|
<span>{file}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="commit-form">
|
<div className={styles.commitForm}>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Commit message"
|
placeholder="Commit message"
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="commit-message-input"
|
className={styles.commitMessageInput}
|
||||||
/>
|
/>
|
||||||
{error && <div className="commit-error">{error}</div>}
|
{error && <div className={styles.commitError}>{error}</div>}
|
||||||
<button
|
<button
|
||||||
onClick={handleCommit}
|
onClick={handleCommit}
|
||||||
disabled={loading || !message.trim()}
|
disabled={loading || !message.trim()}
|
||||||
className="commit-button"
|
className={styles.commitButton}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{loading ? "Committing..." : "Commit"}
|
{loading ? "Committing..." : "Commit"}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
.commitPanel {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitPanel h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileList {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatus {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
width: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem.modified .fileStatus { color: #f59e0b; }
|
||||||
|
.fileItem.added .fileStatus { color: #10b981; }
|
||||||
|
.fileItem.deleted .fileStatus { color: #ef4444; }
|
||||||
|
.fileItem.untracked .fileStatus { color: #6b7280; }
|
||||||
|
|
||||||
|
.commitForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitMessageInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitButton {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitButton:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitError {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
.fileViewer {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileViewerHeader {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileBreadcrumbs {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbSep {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileContent {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileContent 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileViewerEmpty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.instanceList {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceListHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceListHeader h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceCard {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceActions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineConfirm {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
.settingsLayout {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsSidebar {
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNavLink {
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNavLink:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNavLinkActive {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsContent {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsPanel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsBreadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsBreadcrumb a {
|
||||||
|
color: var(--brand);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsBreadcrumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settingsLayout {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsSidebar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNav {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNavLink {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
startInstance,
|
startInstance,
|
||||||
stopInstance,
|
stopInstance,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
|
import styles from "./features/session/InstanceList.module.css";
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
@@ -186,8 +187,8 @@ export const InstanceList = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="instance-list">
|
<div className={styles.instanceList}>
|
||||||
<div className="instance-list-header">
|
<div className={styles.instanceListHeader}>
|
||||||
<h3>Tool Instances</h3>
|
<h3>Tool Instances</h3>
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
@@ -206,30 +207,30 @@ export const InstanceList = ({
|
|||||||
) : instances.length === 0 ? (
|
) : instances.length === 0 ? (
|
||||||
<p className="muted">No instances yet. Launch a tool to get started.</p>
|
<p className="muted">No instances yet. Launch a tool to get started.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="instance-grid">
|
<div className={styles.instanceGrid}>
|
||||||
{instances.map((instance) => (
|
{instances.map((instance) => (
|
||||||
<div key={instance.id} className="instance-card">
|
<div key={instance.id} className={styles.instanceCard}>
|
||||||
<div className="instance-info">
|
<div className={styles.instanceInfo}>
|
||||||
<div className="instance-name">
|
<div className={styles.instanceName}>
|
||||||
{instance.display_name ||
|
{instance.display_name ||
|
||||||
instance.tool_type_name ||
|
instance.tool_type_name ||
|
||||||
"Unnamed Instance"}
|
"Unnamed Instance"}
|
||||||
</div>
|
</div>
|
||||||
<div className="instance-meta">
|
<div className={styles.instanceMeta}>
|
||||||
<span
|
<span
|
||||||
className="status-dot"
|
className={styles.statusDot}
|
||||||
style={{ backgroundColor: getStatusColor(instance.status) }}
|
style={{ backgroundColor: getStatusColor(instance.status) }}
|
||||||
/>
|
/>
|
||||||
{instance.status}
|
{instance.status}
|
||||||
{isTunnelUnhealthy(instance) && (
|
{isTunnelUnhealthy(instance) && (
|
||||||
<span className="error-badge" title="Tunnel unreachable">
|
<span className={styles.errorBadge} title="Tunnel unreachable">
|
||||||
<Icon name="warning" size="sm" />
|
<Icon name="warning" size="sm" />
|
||||||
tunnel error
|
tunnel error
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="instance-actions">
|
<div className={styles.instanceActions}>
|
||||||
{instance.status === "running" &&
|
{instance.status === "running" &&
|
||||||
instance.url &&
|
instance.url &&
|
||||||
instance.tool_type_interfaces.includes("web") && (
|
instance.tool_type_interfaces.includes("web") && (
|
||||||
@@ -286,7 +287,7 @@ export const InstanceList = ({
|
|||||||
{instance.status === "running" && (
|
{instance.status === "running" && (
|
||||||
<>
|
<>
|
||||||
{stopConfirmId === instance.id ? (
|
{stopConfirmId === instance.id ? (
|
||||||
<div className="inline-confirm">
|
<div className={styles.inlineConfirm}>
|
||||||
<span>Stop?</span>
|
<span>Stop?</span>
|
||||||
<button
|
<button
|
||||||
className="ghost-button small danger-text"
|
className="ghost-button small danger-text"
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shellHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
|
backdrop-filter: blur(7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shellBody {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 230px 1fr;
|
||||||
|
min-height: calc(100vh - 57px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shellNav {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem:hover {
|
||||||
|
background: #ece7df;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItemActive {
|
||||||
|
background: var(--brand);
|
||||||
|
color: #f7fff7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navSectionTitle {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navDivider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionItem {
|
||||||
|
position: relative;
|
||||||
|
padding-left: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionStatus {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--space-2);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionStatus.running {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionName {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shellContent {
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.shellBody {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shellNav {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shellContent {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import styles from "./features/settings/SettingsTabLayout.module.css";
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,15 +22,15 @@ export const SettingsTabLayout: React.FC<SettingsTabLayoutProps> = ({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-layout">
|
<div className={styles.settingsLayout}>
|
||||||
<aside className="settings-sidebar">
|
<aside className={styles.settingsSidebar}>
|
||||||
<nav className="settings-nav">
|
<nav className={styles.settingsNav}>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Link
|
<Link
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
to={`${basePath}/${tab.path}`}
|
to={`${basePath}/${tab.path}`}
|
||||||
className={`settings-nav-link ${
|
className={`${styles.settingsNavLink} ${
|
||||||
location.pathname.includes(tab.path) ? "active" : ""
|
location.pathname.includes(tab.path) ? styles.settingsNavLinkActive : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -37,7 +38,7 @@ export const SettingsTabLayout: React.FC<SettingsTabLayoutProps> = ({
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="settings-content">{children}</main>
|
<main className={styles.settingsContent}>{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import "./styles/tokens.css";
|
|||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
import "./styles/utilities.css";
|
import "./styles/utilities.css";
|
||||||
import "./styles/syntax-highlight.css";
|
import "./styles/syntax-highlight.css";
|
||||||
|
import "./styles/pages/sessions.css";
|
||||||
|
import "./styles/pages/repo-workspace.css";
|
||||||
|
import "./styles/pages/dashboard.css";
|
||||||
|
import "./styles/pages/projects.css";
|
||||||
|
import "./styles/pages/git-history.css";
|
||||||
|
import "./styles/pages/ssh-keys.css";
|
||||||
|
import "./styles/pages/settings.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
.home-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid,
|
||||||
|
.home-project-grid,
|
||||||
|
.home-session-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-grid,
|
||||||
|
.home-session-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h2,
|
||||||
|
.settings-header h1,
|
||||||
|
.settings-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h3,
|
||||||
|
.home-section p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
.history-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-selector {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-list.with-detail {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-item:hover {
|
||||||
|
background: #ece7df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-item.selected {
|
||||||
|
border-color: var(--brand);
|
||||||
|
background: #f0f7f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-graph {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--brand);
|
||||||
|
white-space: pre;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-line {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-hash {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--brand);
|
||||||
|
background: #f0f7f4;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-refs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-message {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-detail-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-hash-full {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--brand);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-message-full {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f5f3ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat.additions {
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat.deletions {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat.additions .stat-value {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat.deletions .stat-value {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-hash {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
background: #f5f3ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-content {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: #f5f3ee;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.history-container {
|
||||||
|
grid-template-columns: 1fr 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-list.with-detail {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-detail-panel {
|
||||||
|
grid-column: 2;
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.project-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info h3 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
.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 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-action-btn:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: hidden;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.workspace-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
min-width: auto;
|
||||||
|
max-height: 40vh;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
.sessions-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-session-section {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-session-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-session-info h3 {
|
||||||
|
margin: 0 0 var(--space-1) 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-section {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-section h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-section .badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info h4 {
|
||||||
|
margin: 0 0 var(--space-1) 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-url {
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-url a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-url a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-sessions-section {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-sessions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-inline,
|
||||||
|
.stop-confirm-inline {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-section {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-form {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-form .form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.running {
|
||||||
|
background: var(--success-light, #dcfce7);
|
||||||
|
color: var(--success, #16a34a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.stopped {
|
||||||
|
background: var(--muted-bg, #f3f4f6);
|
||||||
|
color: var(--muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending {
|
||||||
|
background: var(--warning-light, #fef3c7);
|
||||||
|
color: var(--warning, #d97706);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.error {
|
||||||
|
background: var(--danger-light, #fee2e2);
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.settings-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h1,
|
||||||
|
.settings-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions,
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
.keys-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-meta {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-public {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #f5f3ee;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-public code {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-all;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssh-key-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssh-key-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.ssh-key-item {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Task 2.3 Apply Report: Extract CSS Modules for Session/Settings Components and Delete styles.css
|
||||||
|
|
||||||
|
**Status:** Success
|
||||||
|
|
||||||
|
## Files Created (14)
|
||||||
|
|
||||||
|
### CSS Modules
|
||||||
|
- `apps/web/src/components/features/session/InstanceList.module.css` — Instance list, card, meta, actions, status dot, error badge, inline confirm
|
||||||
|
- `apps/web/src/components/layout/AppShell.module.css` — Shell layout, header, nav, session item, nav badge, responsive queries
|
||||||
|
- `apps/web/src/components/features/settings/SettingsTabLayout.module.css` — Settings layout, sidebar, nav links, panel, breadcrumb, responsive queries
|
||||||
|
- `apps/web/src/components/features/git/CommitPanel.module.css` — Commit panel, file list, file item, commit form, button
|
||||||
|
- `apps/web/src/components/features/git/FileViewer.module.css` — File viewer, header, breadcrumbs, content, empty state
|
||||||
|
|
||||||
|
### Page CSS Files
|
||||||
|
- `apps/web/src/styles/pages/sessions.css` — Sessions page layout, last session, active/recent sessions, create form, status badges
|
||||||
|
- `apps/web/src/styles/pages/repo-workspace.css` — Repo workspace, header, layout, sidebar, responsive queries
|
||||||
|
- `apps/web/src/styles/pages/dashboard.css` — Home page, hero, summary/project/session grids
|
||||||
|
- `apps/web/src/styles/pages/projects.css` — Project list, project card, actions
|
||||||
|
- `apps/web/src/styles/pages/git-history.css` — History container, commit list, detail panel, stats, diff
|
||||||
|
- `apps/web/src/styles/pages/ssh-keys.css` — Key list, key card, SSH key item
|
||||||
|
- `apps/web/src/styles/pages/settings.css` — Settings page, tabs, panel, actions
|
||||||
|
|
||||||
|
## Files Modified (6)
|
||||||
|
|
||||||
|
- `apps/web/src/components/instance-list.tsx` — Import InstanceList.module.css, replace className strings with styles.* references
|
||||||
|
- `apps/web/src/components/app-shell.tsx` — Import AppShell.module.css, replace shell/nav/session class names
|
||||||
|
- `apps/web/src/components/settings-tab-layout.tsx` — Import SettingsTabLayout.module.css, replace settings class names
|
||||||
|
- `apps/web/src/components/commit-panel.tsx` — Import CommitPanel.module.css, replace commit panel class names
|
||||||
|
- `apps/web/src/main.tsx` — Import all page CSS files
|
||||||
|
|
||||||
|
## Files Deleted (1)
|
||||||
|
|
||||||
|
- `apps/web/src/styles.css` — Monolithic 2,255-line stylesheet deleted
|
||||||
|
|
||||||
|
## Quality Gate Results
|
||||||
|
|
||||||
|
| Gate | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `npm run typecheck` | ✅ PASS — zero errors |
|
||||||
|
| `npm run lint` | ✅ PASS — zero warnings |
|
||||||
|
| `npm run build` | ✅ PASS — build succeeds, CSS 40.29 kB |
|
||||||
|
| `styles.css deleted` | ✅ PASS — `test -f styles.css` fails |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All component-specific CSS has been extracted into `.module.css` files
|
||||||
|
- All page-specific CSS has been extracted into `styles/pages/*.css` files
|
||||||
|
- Generic utilities (.stack, .row, .card, .button, .dialog, .form-field) remain in `styles/utilities.css`
|
||||||
|
- Shell layout and resets remain in `styles/global.css`
|
||||||
|
- Design tokens remain in `styles/tokens.css`
|
||||||
|
- Syntax highlighting remains in `styles/syntax-highlight.css`
|
||||||
|
- No visual regressions expected since all rules are preserved, just reorganized
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Task 3.4 Apply Report: Slim tool_instances Router to HTTP-Only Concerns
|
||||||
|
|
||||||
|
**Status:** Success
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Reduced `apps/api/src/api/tool_instances.py` from **1,412 lines to 284 lines** — an 80% reduction. The router now contains only HTTP routing concerns.
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
- `apps/api/src/services/instance_lifecycle.py` (420 lines) — High-level orchestration service coordinating Docker compose, container, tunnel, and config staging services for create/start/stop/restart/delete operations.
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `apps/api/src/services/docker/compose.py` — Added helper functions:
|
||||||
|
- `_sanitize_name()` — Docker name sanitization
|
||||||
|
- `_generate_instance_name()` — Sequential instance naming
|
||||||
|
- `_modify_compose_file()` — Compose file runtime overrides
|
||||||
|
- `_apply_resolved_profile()` — Profile resolution and application
|
||||||
|
|
||||||
|
- `apps/api/src/api/tool_instances.py` — Slimmed from 1,412 to 284 lines:
|
||||||
|
- Removed all business logic (Docker calls, compose manipulation, tunnel management)
|
||||||
|
- Removed 8 helper functions (moved to services)
|
||||||
|
- Endpoints are now thin: validation → service call → response
|
||||||
|
|
||||||
|
## Quality Gate Results
|
||||||
|
|
||||||
|
| Gate | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `python3 -m py_compile api/tool_instances.py` | ✅ PASS |
|
||||||
|
| `python3 -m py_compile services/instance_lifecycle.py` | ✅ PASS |
|
||||||
|
| `python3 -m py_compile services/docker/compose.py` | ✅ PASS |
|
||||||
|
| `wc -l api/tool_instances.py` | ✅ 284 lines (≤300) |
|
||||||
|
| `grep -n "subprocess" api/tool_instances.py` | ✅ 0 results |
|
||||||
|
| `grep -n "docker" api/tool_instances.py` | ✅ 5 results (all imports/variable names, no CLI calls) |
|
||||||
|
| `npm run typecheck` (frontend) | ✅ PASS |
|
||||||
|
| `npm run lint` (frontend) | ✅ PASS |
|
||||||
|
|
||||||
|
## Router Structure (After)
|
||||||
|
|
||||||
|
```
|
||||||
|
284 lines total:
|
||||||
|
- 20 lines: imports
|
||||||
|
- 22 lines: _get_instance + _get_repo helpers
|
||||||
|
- 242 lines: 11 endpoint handlers (avg 22 lines each)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each endpoint:
|
||||||
|
1. Validates input (fetches instance/repo, checks auth)
|
||||||
|
2. Calls a single service function
|
||||||
|
3. Returns response
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `services/instance_lifecycle.py` was actually created and committed by the parallel Task 2.2/3.2 worker run; this commit finalized the router slimming.
|
||||||
|
- The `get_user_sessions` endpoint at the bottom of the original router (on `sessions_router`) was already removed in a previous commit.
|
||||||
|
- No behavior changes — all endpoint signatures and response shapes preserved.
|
||||||
Reference in New Issue
Block a user