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:
Developer
2026-06-02 21:09:57 +00:00
parent c8c490eb2b
commit cccf4379d8
27 changed files with 2146 additions and 3388 deletions
+52 -296
View File
@@ -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
+81
View File
@@ -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",
)
+1
View File
@@ -0,0 +1 @@
"""Git services package."""
+196
View File
@@ -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))
+150
View File
@@ -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))
+211
View File
@@ -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())
+15 -14
View File
@@ -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>
+19 -18
View File
@@ -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;
}
}
+12 -11
View File
@@ -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>
); );
}; };
+7
View File
@@ -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
+51
View File
@@ -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);
}
+255
View File
@@ -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;
}
}
+33
View File
@@ -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;
}
}
+176
View File
@@ -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);
}
+40
View File
@@ -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;
}
+66
View File
@@ -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.