refactor: slim git_repositories and config_profiles routers (Task 3.5)

- Extract git control operations to services/git/control.py with repo validation
- Extract git file operations to services/git/files.py with repo validation
- Extract repository lifecycle to services/git/repository.py (create, delete, list)
- Extract config profile helpers to services/config_profiles.py
  (cycle detection, duplicate checks, serialization, default profile management)
- Slim git_repositories.py from ~1050 to 276 lines
- Slim config_profiles.py from ~765 to 299 lines
- Both routers now contain only HTTP routing concerns

Quality gates: py_compile (pass), file size ≤300 (pass), no subprocess in routers (pass)
Refs: repo-restructure Task 3.5
This commit is contained in:
Developer
2026-06-02 21:28:15 +00:00
parent cccf4379d8
commit dd69bd69fc
18 changed files with 1040 additions and 1106 deletions
+50 -272
View File
@@ -24,7 +24,18 @@ from src.schemas.config_profile import (
DefaultProfilesUpdate, DefaultProfilesUpdate,
) )
from src.services.config_profiles import ( from src.services.config_profiles import (
check_duplicate_include,
check_duplicate_mount_path,
check_duplicate_name,
get_default_profile_for_tool_type,
get_default_profiles,
get_owned_profile, get_owned_profile,
include_to_dict,
list_includes_for_profile,
list_mounts_for_profile,
mount_to_dict,
profile_to_dict,
set_default_profiles,
validate_includes_no_cycle, validate_includes_no_cycle,
) )
@@ -33,11 +44,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"]) router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
# ---------------------------------------------------------------------------
# Profile CRUD
# ---------------------------------------------------------------------------
@router.get("", summary="List config profiles") @router.get("")
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),
@@ -48,107 +56,51 @@ async def list_config_profiles(
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(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
result = await session.execute(query.order_by(ConfigProfile.name)) result = await session.execute(query.order_by(ConfigProfile.name))
profiles = result.scalars().all() return {"profiles": [profile_to_dict(p) for p in result.scalars().all()]}
return {
"profiles": [
{
"id": str(p.id),
"user_id": str(p.user_id),
"name": p.name,
"description": p.description,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
for p in profiles
]
}
@router.post("", status_code=status.HTTP_201_CREATED, summary="Create config profile") @router.post("", 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:
existing = await session.scalar( await check_duplicate_name(session, user_id, data.name)
select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == data.name,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config profile with name '{data.name}' already exists",
)
profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description) profile = ConfigProfile(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)
return profile_to_dict(profile)
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.get("/defaults", summary="Get default profiles") @router.get("/defaults")
async def get_default_profiles( async def get_default_profiles_endpoint(
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:
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) return await get_default_profiles(session, user_id)
user_config = result.scalar_one_or_none()
return {"default_profiles": user_config.default_profiles if user_config else {}}
@router.put("/defaults", summary="Set default profiles") @router.put("/defaults")
async def set_default_profiles( async def set_default_profiles_endpoint(
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:
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) return await set_default_profiles(session, user_id, data.default_profiles)
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
for tool_type_id, profile_id_str in data.default_profiles.items():
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found")
if profile.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user")
user_config.config = {**user_config.config, "default_profiles": data.default_profiles}
await session.commit()
await session.refresh(user_config)
return {"default_profiles": user_config.default_profiles}
@router.get("/defaults/{tool_type_id}", summary="Get default profile for tool type") @router.get("/defaults/{tool_type_id}")
async def get_default_profile_for_tool_type( async def get_default_profile_for_tool_type_endpoint(
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:
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) return await get_default_profile_for_tool_type(session, user_id, tool_type_id)
user_config = result.scalar_one_or_none()
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
@router.get("/{profile_id}", summary="Get config profile") @router.get("/{profile_id}")
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),
@@ -161,47 +113,18 @@ async def get_config_profile(
) )
if profile is None or profile.user_id != user_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") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
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)
includes_data.append({ includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
"id": str(inc.id),
"profile_id": str(inc.profile_id),
"included_profile_id": str(inc.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": inc.order_index,
"created_at": inc.created_at.isoformat() if inc.created_at else None,
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
})
mounts_data = [
{
"id": str(m.id),
"profile_id": str(m.profile_id),
"target_path": m.target_path,
"mode": m.mode,
"files": m.files,
"order_index": m.order_index,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
for m in profile.mounts
]
return { return {
"id": str(profile.id), **profile_to_dict(profile),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"includes": includes_data, "includes": includes_data,
"mounts": mounts_data, "mounts": [mount_to_dict(m) for m in profile.mounts],
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
} }
@router.put("/{profile_id}", summary="Update config profile") @router.put("/{profile_id}")
async def update_config_profile( async def update_config_profile(
profile_id: uuid.UUID, profile_id: uuid.UUID,
data: ConfigProfileUpdate, data: ConfigProfileUpdate,
@@ -209,39 +132,17 @@ async def update_config_profile(
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> dict: ) -> dict:
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:
existing = await session.scalar( await check_duplicate_name(session, user_id, data.name, exclude_id=profile_id)
select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == data.name,
ConfigProfile.id != profile_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config profile with name '{data.name}' already exists",
)
profile.name = data.name profile.name = data.name
if data.description is not None: if data.description is not None:
profile.description = data.description profile.description = data.description
await session.commit() await session.commit()
await session.refresh(profile) await session.refresh(profile)
return profile_to_dict(profile)
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete config profile") @router.delete("/{profile_id}", 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),
@@ -252,41 +153,18 @@ async def delete_config_profile(
await session.commit() await session.commit()
# ---------------------------------------------------------------------------
# Include management
# ---------------------------------------------------------------------------
@router.get("/{profile_id}/includes", summary="List profile includes") @router.get("/{profile_id}/includes")
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:
await get_owned_profile(profile_id, user_id, session) await get_owned_profile(profile_id, user_id, session)
result = await session.execute( return await list_includes_for_profile(session, profile_id)
select(ConfigInclude)
.where(ConfigInclude.profile_id == profile_id)
.order_by(ConfigInclude.order_index)
)
includes = result.scalars().all()
includes_data = []
for inc in includes:
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
includes_data.append({
"id": str(inc.id),
"profile_id": str(inc.profile_id),
"included_profile_id": str(inc.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": inc.order_index,
"created_at": inc.created_at.isoformat() if inc.created_at else None,
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
})
return {"includes": includes_data}
@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED, summary="Add profile include") @router.post("/{profile_id}/includes", 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,
@@ -295,27 +173,15 @@ async def add_profile_include(
) -> dict: ) -> dict:
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)
if included_profile_id == profile_id: if included_profile_id == profile_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself")
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(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found") raise HTTPException(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(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user")
await check_duplicate_include(session, profile_id, included_profile_id)
existing = await session.scalar(
select(ConfigInclude).where(
ConfigInclude.profile_id == profile_id,
ConfigInclude.included_profile_id == included_profile_id,
)
)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="this include already exists")
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,
included_profile_id=included_profile_id, included_profile_id=included_profile_id,
@@ -324,19 +190,10 @@ async def add_profile_include(
session.add(include) session.add(include)
await session.commit() await session.commit()
await session.refresh(include) await session.refresh(include)
return include_to_dict(include, included_profile.name)
return {
"id": str(include.id),
"profile_id": str(include.profile_id),
"included_profile_id": str(include.included_profile_id),
"included_profile_name": included_profile.name,
"order_index": include.order_index,
"created_at": include.created_at.isoformat() if include.created_at else None,
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
}
@router.put("/{profile_id}/includes/{include_id}", summary="Update profile include") @router.put("/{profile_id}/includes/{include_id}")
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,
@@ -348,24 +205,14 @@ async def update_profile_include(
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(status_code=status.HTTP_404_NOT_FOUND, detail="include not found") raise HTTPException(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()
await session.refresh(include) await session.refresh(include)
included_profile = await session.get(ConfigProfile, include.included_profile_id) included_profile = await session.get(ConfigProfile, include.included_profile_id)
return { return include_to_dict(include, included_profile.name if included_profile else None)
"id": str(include.id),
"profile_id": str(include.profile_id),
"included_profile_id": str(include.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": include.order_index,
"created_at": include.created_at.isoformat() if include.created_at else None,
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
}
@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove profile include") @router.delete("/{profile_id}/includes/{include_id}", 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,
@@ -380,42 +227,18 @@ async def remove_profile_include(
await session.commit() await session.commit()
# ---------------------------------------------------------------------------
# Mount management
# ---------------------------------------------------------------------------
@router.get("/{profile_id}/mounts", summary="List profile mounts") @router.get("/{profile_id}/mounts")
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:
await get_owned_profile(profile_id, user_id, session) await get_owned_profile(profile_id, user_id, session)
result = await session.execute( return await list_mounts_for_profile(session, profile_id)
select(ConfigMount)
.where(ConfigMount.profile_id == profile_id)
.order_by(ConfigMount.order_index)
)
mounts = result.scalars().all()
return {
"mounts": [
{
"id": str(m.id),
"profile_id": str(m.profile_id),
"target_path": m.target_path,
"files": m.files,
"mode": m.mode,
"order_index": m.order_index,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
for m in mounts
]
}
@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED, summary="Add profile mount") @router.post("/{profile_id}/mounts", 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,
@@ -423,18 +246,7 @@ async def add_profile_mount(
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> dict: ) -> dict:
profile = await get_owned_profile(profile_id, user_id, session) profile = await get_owned_profile(profile_id, user_id, session)
existing = await session.scalar( await check_duplicate_mount_path(session, profile_id, data.target_path)
select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
ConfigMount.target_path == data.target_path,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"mount with path '{data.target_path}' already exists",
)
mount = ConfigMount( mount = ConfigMount(
profile_id=profile_id, profile_id=profile_id,
target_path=data.target_path, target_path=data.target_path,
@@ -445,20 +257,10 @@ async def add_profile_mount(
session.add(mount) session.add(mount)
await session.commit() await session.commit()
await session.refresh(mount) await session.refresh(mount)
return mount_to_dict(mount)
return {
"id": str(mount.id),
"profile_id": str(mount.profile_id),
"target_path": mount.target_path,
"files": mount.files,
"mode": mount.mode,
"order_index": mount.order_index,
"created_at": mount.created_at.isoformat() if mount.created_at else None,
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
}
@router.put("/{profile_id}/mounts/{mount_id}", summary="Update profile mount") @router.put("/{profile_id}/mounts/{mount_id}")
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,
@@ -470,43 +272,19 @@ async def update_profile_mount(
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(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found") raise HTTPException(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:
existing = await session.scalar( await check_duplicate_mount_path(session, profile_id, data.target_path, exclude_id=mount_id)
select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
ConfigMount.target_path == data.target_path,
ConfigMount.id != mount_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"mount with path '{data.target_path}' already exists",
)
mount.target_path = data.target_path mount.target_path = data.target_path
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
await session.commit() await session.commit()
await session.refresh(mount) await session.refresh(mount)
return mount_to_dict(mount)
return {
"id": str(mount.id),
"profile_id": str(mount.profile_id),
"target_path": mount.target_path,
"files": mount.files,
"mode": mount.mode,
"order_index": mount.order_index,
"created_at": mount.created_at.isoformat() if mount.created_at else None,
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
}
@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove profile mount") @router.delete("/{profile_id}/mounts/{mount_id}", 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,
+20 -99
View File
@@ -35,15 +35,10 @@ from src.services.git.repository import create_repository, delete_repository, li
from src.utils.git_url_parser import parse_git_url from src.utils.git_url_parser import parse_git_url
router = APIRouter(prefix="/projects", tags=["git-repositories"]) router = APIRouter(prefix="/projects", tags=["git-repositories"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@router.get( @router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
"/{project_id}/repositories",
response_model=list[GitRepositoryResponse],
summary="List repositories",
)
async def list_repositories_endpoint( async def list_repositories_endpoint(
project_id: uuid.UUID, project_id: uuid.UUID,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
@@ -53,12 +48,7 @@ async def list_repositories_endpoint(
return await list_repositories(session, project_id) return await list_repositories(session, project_id)
@router.post( @router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
"/{project_id}/repositories",
response_model=GitRepositoryResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a repository",
)
async def create_repository_endpoint( async def create_repository_endpoint(
project_id: uuid.UUID, project_id: uuid.UUID,
data: GitRepositoryCreate, data: GitRepositoryCreate,
@@ -69,11 +59,7 @@ async def create_repository_endpoint(
return await create_repository(session, project_id, data, user) return await create_repository(session, project_id, data, user)
@router.delete( @router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT)
"/{project_id}/repositories/{repo_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a repository",
)
async def delete_repository_endpoint( async def delete_repository_endpoint(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -85,23 +71,14 @@ async def delete_repository_endpoint(
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post( @router.post("/repositories/parse-url", response_model=URLParseResponse)
"/repositories/parse-url",
response_model=URLParseResponse,
summary="Parse a git URL",
)
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
result = parse_git_url(data.url) return URLParseResponse(**parse_git_url(data.url))
return URLParseResponse(**result)
# History # History
@router.get("/{project_id}/repositories/{repo_id}/history")
@router.get(
"/{project_id}/repositories/{repo_id}/history",
summary="Get repository history",
)
async def get_repository_history( async def get_repository_history(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -115,16 +92,12 @@ async def get_repository_history(
) -> dict: ) -> dict:
from src.utils.git_history import get_commit_history from src.utils.git_history import get_commit_history
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
repo = await get_repo_and_validate(session, repo_id, project_id) repo = await get_repo_and_validate(session, repo_id, project_id)
ensure_repo_on_disk(repo) ensure_repo_on_disk(repo)
return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset) return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
@router.get( @router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
"/{project_id}/repositories/{repo_id}/commits/{commit_hash}",
summary="Get commit details",
)
async def get_repository_commit( async def get_repository_commit(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -135,7 +108,6 @@ async def get_repository_commit(
) -> dict: ) -> dict:
from src.utils.git_history import get_commit_detail from src.utils.git_history import get_commit_detail
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
repo = await get_repo_and_validate(session, repo_id, project_id) repo = await get_repo_and_validate(session, repo_id, project_id)
ensure_repo_on_disk(repo) ensure_repo_on_disk(repo)
return get_commit_detail(repo.path, commit_hash) return get_commit_detail(repo.path, commit_hash)
@@ -143,12 +115,7 @@ async def get_repository_commit(
# File browsing # File browsing
@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse)
@router.get(
"/{project_id}/repositories/{repo_id}/files",
response_model=FileListResponse,
summary="List repository files",
)
async def list_repository_files( async def list_repository_files(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -161,11 +128,7 @@ async def list_repository_files(
return await git_files.list_files(session, project_id, repo_id, branch, path) return await git_files.list_files(session, project_id, repo_id, branch, path)
@router.get( @router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse)
"/{project_id}/repositories/{repo_id}/files/content",
response_model=FileContentResponse,
summary="Get file content",
)
async def get_repository_file_content( async def get_repository_file_content(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -178,11 +141,7 @@ async def get_repository_file_content(
return await git_files.get_file(session, project_id, repo_id, branch, path) return await git_files.get_file(session, project_id, repo_id, branch, path)
@router.post( @router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse)
"/{project_id}/repositories/{repo_id}/files/content",
response_model=FileUpdateResponse,
summary="Update file content",
)
async def update_repository_file( async def update_repository_file(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -196,11 +155,7 @@ async def update_repository_file(
# Branches # Branches
@router.get("/{project_id}/repositories/{repo_id}/branches")
@router.get(
"/{project_id}/repositories/{repo_id}/branches",
summary="List branches",
)
async def get_repository_branches( async def get_repository_branches(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -211,10 +166,7 @@ async def get_repository_branches(
return await git_files.list_branches_with_validation(session, project_id, repo_id) return await git_files.list_branches_with_validation(session, project_id, repo_id)
@router.post( @router.post("/{project_id}/repositories/{repo_id}/branches")
"/{project_id}/repositories/{repo_id}/branches",
summary="Create a branch",
)
async def create_repository_branch( async def create_repository_branch(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -226,10 +178,7 @@ async def create_repository_branch(
return await git_control.create_branch_with_validation(session, project_id, repo_id, data) return await git_control.create_branch_with_validation(session, project_id, repo_id, data)
@router.delete( @router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}")
"/{project_id}/repositories/{repo_id}/branches/{branch_name}",
summary="Delete a branch",
)
async def delete_repository_branch( async def delete_repository_branch(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -242,10 +191,7 @@ async def delete_repository_branch(
return await git_control.delete_branch_with_validation(session, project_id, repo_id, branch_name, force) return await git_control.delete_branch_with_validation(session, project_id, repo_id, branch_name, force)
@router.post( @router.post("/{project_id}/repositories/{repo_id}/checkout")
"/{project_id}/repositories/{repo_id}/checkout",
summary="Checkout a branch",
)
async def checkout_repository_branch( async def checkout_repository_branch(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -259,12 +205,7 @@ async def checkout_repository_branch(
# Git control # Git control
@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse)
@router.get(
"/{project_id}/repositories/{repo_id}/status",
response_model=StatusResponse,
summary="Get repository status",
)
async def get_repository_status( async def get_repository_status(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -275,11 +216,7 @@ async def get_repository_status(
return await git_control.get_status_with_validation(session, project_id, repo_id) return await git_control.get_status_with_validation(session, project_id, repo_id)
@router.post( @router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse)
"/{project_id}/repositories/{repo_id}/commit",
response_model=CommitResponse,
summary="Commit changes",
)
async def commit_repository_changes( async def commit_repository_changes(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -292,11 +229,7 @@ async def commit_repository_changes(
return CommitResponse(commit_hash=result["commit_hash"], message=result["message"]) return CommitResponse(commit_hash=result["commit_hash"], message=result["message"])
@router.post( @router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse)
"/{project_id}/repositories/{repo_id}/fetch",
response_model=FetchResponse,
summary="Fetch from remote",
)
async def fetch_repository( async def fetch_repository(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -307,11 +240,7 @@ async def fetch_repository(
return await git_control.fetch_with_validation(session, project_id, repo_id) return await git_control.fetch_with_validation(session, project_id, repo_id)
@router.post( @router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse)
"/{project_id}/repositories/{repo_id}/pull",
response_model=PullResponse,
summary="Pull from remote",
)
async def pull_repository( async def pull_repository(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -323,11 +252,7 @@ async def pull_repository(
return await git_control.pull_with_validation(session, project_id, repo_id, branch) return await git_control.pull_with_validation(session, project_id, repo_id, branch)
@router.post( @router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse)
"/{project_id}/repositories/{repo_id}/push",
response_model=PushResponse,
summary="Push to remote",
)
async def push_repository( async def push_repository(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
@@ -339,11 +264,7 @@ async def push_repository(
return await git_control.push_with_validation(session, project_id, repo_id, branch) return await git_control.push_with_validation(session, project_id, repo_id, branch)
@router.post( @router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse)
"/{project_id}/repositories/{repo_id}/merge",
response_model=MergeResponse,
summary="Merge branches",
)
async def merge_repository_branches( async def merge_repository_branches(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
+218
View File
@@ -6,6 +6,7 @@ import uuid
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.models.config_include import ConfigInclude from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount from src.models.config_mount import ConfigMount
@@ -79,3 +80,220 @@ async def validate_includes_no_cycle(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="adding this include would create a circular reference", detail="adding this include would create a circular reference",
) )
# ---------------------------------------------------------------------------
# Profile CRUD helpers
# ---------------------------------------------------------------------------
async def check_duplicate_name(
session: AsyncSession,
user_id: uuid.UUID,
name: str,
exclude_id: uuid.UUID | None = None,
) -> None:
"""Raise 409 if a profile with the given name already exists."""
query = select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == name,
)
if exclude_id:
query = query.where(ConfigProfile.id != exclude_id)
existing = await session.scalar(query)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config profile with name '{name}' already exists",
)
def profile_to_dict(profile: ConfigProfile) -> dict:
"""Serialize a ConfigProfile to a dict."""
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
# ---------------------------------------------------------------------------
# Include helpers
# ---------------------------------------------------------------------------
async def check_duplicate_include(
session: AsyncSession,
profile_id: uuid.UUID,
included_profile_id: uuid.UUID,
) -> None:
"""Raise 409 if the include already exists."""
existing = await session.scalar(
select(ConfigInclude).where(
ConfigInclude.profile_id == profile_id,
ConfigInclude.included_profile_id == included_profile_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="this include already exists",
)
def include_to_dict(inc: ConfigInclude, included_name: str | None) -> dict:
"""Serialize a ConfigInclude to a dict."""
return {
"id": str(inc.id),
"profile_id": str(inc.profile_id),
"included_profile_id": str(inc.included_profile_id),
"included_profile_name": included_name,
"order_index": inc.order_index,
"created_at": inc.created_at.isoformat() if inc.created_at else None,
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
}
# ---------------------------------------------------------------------------
# Mount helpers
# ---------------------------------------------------------------------------
async def check_duplicate_mount_path(
session: AsyncSession,
profile_id: uuid.UUID,
target_path: str,
exclude_id: uuid.UUID | None = None,
) -> None:
"""Raise 409 if a mount with the given path already exists."""
query = select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
ConfigMount.target_path == target_path,
)
if exclude_id:
query = query.where(ConfigMount.id != exclude_id)
existing = await session.scalar(query)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"mount with path '{target_path}' already exists",
)
def mount_to_dict(mount: ConfigMount) -> dict:
"""Serialize a ConfigMount to a dict."""
return {
"id": str(mount.id),
"profile_id": str(mount.profile_id),
"target_path": mount.target_path,
"files": mount.files,
"mode": mount.mode,
"order_index": mount.order_index,
"created_at": mount.created_at.isoformat() if mount.created_at else None,
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
}
# ---------------------------------------------------------------------------
# Default profile helpers
# ---------------------------------------------------------------------------
async def get_or_create_user_config(
session: AsyncSession,
user_id: uuid.UUID,
) -> UserConfig:
"""Get existing user config or create a new one."""
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
return user_config
async def validate_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> None:
"""Validate that all profile IDs in default_profiles belong to the user."""
for tool_type_id, profile_id_str in default_profiles.items():
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found")
if profile.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user")
async def get_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
) -> dict:
"""Get default profiles for a user."""
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
user_config = result.scalar_one_or_none()
return {"default_profiles": user_config.default_profiles if user_config else {}}
async def set_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> dict:
"""Set default profiles for a user."""
user_config = await get_or_create_user_config(session, user_id)
await validate_default_profiles(session, user_id, default_profiles)
user_config.config = {**user_config.config, "default_profiles": default_profiles}
await session.commit()
await session.refresh(user_config)
return {"default_profiles": user_config.default_profiles}
async def get_default_profile_for_tool_type(
session: AsyncSession,
user_id: uuid.UUID,
tool_type_id: str,
) -> dict:
"""Get default profile for a specific tool type."""
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
user_config = result.scalar_one_or_none()
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
# ---------------------------------------------------------------------------
# Include list helper
# ---------------------------------------------------------------------------
async def list_includes_for_profile(
session: AsyncSession,
profile_id: uuid.UUID,
) -> dict:
"""List all includes for a profile."""
result = await session.execute(
select(ConfigInclude)
.where(ConfigInclude.profile_id == profile_id)
.order_by(ConfigInclude.order_index)
)
includes_data = []
for inc in result.scalars().all():
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
return {"includes": includes_data}
# ---------------------------------------------------------------------------
# Mount list helper
# ---------------------------------------------------------------------------
async def list_mounts_for_profile(
session: AsyncSession,
profile_id: uuid.UUID,
) -> dict:
"""List all mounts for a profile."""
result = await session.execute(
select(ConfigMount)
.where(ConfigMount.profile_id == profile_id)
.order_by(ConfigMount.order_index)
)
return {"mounts": [mount_to_dict(m) for m in result.scalars().all()]}
+6 -2
View File
@@ -32,7 +32,9 @@ const SessionItem = ({ session }: { session: Session }) => {
className={`${styles.navItem} ${styles.sessionItem}`} className={`${styles.navItem} ${styles.sessionItem}`}
title={`${displayName} (${session.status})`} title={`${displayName} (${session.status})`}
> >
<span className={`${styles.sessionStatus} ${isRunning ? styles.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={styles.sessionName}>{displayName}</span> <span className={styles.sessionName}>{displayName}</span>
</a> </a>
@@ -97,7 +99,9 @@ export const AppShell = () => {
key={item.to} key={item.to}
to={item.to} to={item.to}
className={({ isActive }) => className={({ isActive }) =>
isActive ? `${styles.navItem} ${styles.navItemActive}` : styles.navItem isActive
? `${styles.navItem} ${styles.navItemActive}`
: styles.navItem
} }
end={item.to === "/"} end={item.to === "/"}
> >
+87 -87
View File
@@ -4,100 +4,100 @@ import { commitChanges } from "../api/git_repositories";
import styles from "./features/git/CommitPanel.module.css"; import styles from "./features/git/CommitPanel.module.css";
interface CommitPanelProps { interface CommitPanelProps {
projectId: string; projectId: string;
repoId: string; repoId: string;
modified: string[]; modified: string[];
added: string[]; added: string[];
deleted: string[]; deleted: string[];
untracked: string[]; untracked: string[];
onCommit: () => void; onCommit: () => void;
} }
export const CommitPanel = ({ export const CommitPanel = ({
projectId, projectId,
repoId, repoId,
modified, modified,
added, added,
deleted, deleted,
untracked, untracked,
onCommit, onCommit,
}: CommitPanelProps) => { }: CommitPanelProps) => {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const allFiles = [...modified, ...added, ...deleted, ...untracked]; const allFiles = [...modified, ...added, ...deleted, ...untracked];
const hasChanges = allFiles.length > 0; const hasChanges = allFiles.length > 0;
const handleCommit = async () => { const handleCommit = async () => {
if (!message.trim()) { if (!message.trim()) {
setError("Please enter a commit message"); setError("Please enter a commit message");
return; return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
await commitChanges(projectId, repoId, message); await commitChanges(projectId, repoId, message);
setMessage(""); setMessage("");
onCommit(); onCommit();
} catch { } catch {
setError("Commit failed. Please try again."); setError("Commit failed. Please try again.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (!hasChanges) return null; if (!hasChanges) return null;
return ( return (
<div className={styles.commitPanel}> <div className={styles.commitPanel}>
<h4>Changes</h4> <h4>Changes</h4>
<div className={styles.fileList}>
{modified.map((file) => (
<div key={file} className={`${styles.fileItem} modified`}>
<span className={styles.fileStatus}>M</span>
<span>{file}</span>
</div>
))}
{added.map((file) => (
<div key={file} className={`${styles.fileItem} added`}>
<span className={styles.fileStatus}>A</span>
<span>{file}</span>
</div>
))}
{deleted.map((file) => (
<div key={file} className={`${styles.fileItem} deleted`}>
<span className={styles.fileStatus}>D</span>
<span>{file}</span>
</div>
))}
{untracked.map((file) => (
<div key={file} className={`${styles.fileItem} untracked`}>
<span className={styles.fileStatus}>?</span>
<span>{file}</span>
</div>
))}
</div>
<div className={styles.commitForm}> <div className={styles.fileList}>
<textarea {modified.map((file) => (
placeholder="Commit message" <div key={file} className={`${styles.fileItem} modified`}>
value={message} <span className={styles.fileStatus}>M</span>
onChange={(e) => setMessage(e.target.value)} <span>{file}</span>
rows={2} </div>
className={styles.commitMessageInput} ))}
/> {added.map((file) => (
{error && <div className={styles.commitError}>{error}</div>} <div key={file} className={`${styles.fileItem} added`}>
<button <span className={styles.fileStatus}>A</span>
onClick={handleCommit} <span>{file}</span>
disabled={loading || !message.trim()} </div>
className={styles.commitButton} ))}
type="button" {deleted.map((file) => (
> <div key={file} className={`${styles.fileItem} deleted`}>
{loading ? "Committing..." : "Commit"} <span className={styles.fileStatus}>D</span>
</button> <span>{file}</span>
</div> </div>
</div> ))}
); {untracked.map((file) => (
<div key={file} className={`${styles.fileItem} untracked`}>
<span className={styles.fileStatus}>?</span>
<span>{file}</span>
</div>
))}
</div>
<div className={styles.commitForm}>
<textarea
placeholder="Commit message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
className={styles.commitMessageInput}
/>
{error && <div className={styles.commitError}>{error}</div>}
<button
onClick={handleCommit}
disabled={loading || !message.trim()}
className={styles.commitButton}
type="button"
>
{loading ? "Committing..." : "Commit"}
</button>
</div>
</div>
);
}; };
@@ -1,76 +1,84 @@
.commitPanel { .commitPanel {
padding: 1rem; padding: 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
background: var(--panel); background: var(--panel);
} }
.commitPanel h4 { .commitPanel h4 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
} }
.fileList { .fileList {
max-height: 150px; max-height: 150px;
overflow: auto; overflow: auto;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.fileItem { .fileItem {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.25rem 0; padding: 0.25rem 0;
font-size: 0.8125rem; font-size: 0.8125rem;
} }
.fileStatus { .fileStatus {
font-weight: bold; font-weight: bold;
font-size: 0.75rem; font-size: 0.75rem;
width: 1rem; width: 1rem;
text-align: center; text-align: center;
} }
.fileItem.modified .fileStatus { color: #f59e0b; } .fileItem.modified .fileStatus {
.fileItem.added .fileStatus { color: #10b981; } color: #f59e0b;
.fileItem.deleted .fileStatus { color: #ef4444; } }
.fileItem.untracked .fileStatus { color: #6b7280; } .fileItem.added .fileStatus {
color: #10b981;
}
.fileItem.deleted .fileStatus {
color: #ef4444;
}
.fileItem.untracked .fileStatus {
color: #6b7280;
}
.commitForm { .commitForm {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.commitMessageInput { .commitMessageInput {
width: 100%; width: 100%;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
background: var(--bg); background: var(--bg);
color: var(--ink); color: var(--ink);
font-family: inherit; font-family: inherit;
font-size: 0.875rem; font-size: 0.875rem;
resize: vertical; resize: vertical;
} }
.commitButton { .commitButton {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--primary); background: var(--primary);
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
} }
.commitButton:disabled { .commitButton:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.commitError { .commitError {
color: #ef4444; color: #ef4444;
font-size: 0.8125rem; font-size: 0.8125rem;
} }
@@ -1,45 +1,45 @@
.fileViewer { .fileViewer {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
} }
.fileViewerHeader { .fileViewerHeader {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg); background: var(--bg);
} }
.fileBreadcrumbs { .fileBreadcrumbs {
font-size: 0.875rem; font-size: 0.875rem;
font-family: monospace; font-family: monospace;
} }
.breadcrumbSep { .breadcrumbSep {
color: var(--muted); color: var(--muted);
margin: 0 0.25rem; margin: 0 0.25rem;
} }
.fileContent { .fileContent {
padding: 1rem; padding: 1rem;
overflow: auto; overflow: auto;
max-height: calc(100vh - 200px); max-height: calc(100vh - 200px);
} }
.fileContent pre { .fileContent pre {
margin: 0; margin: 0;
font-family: "IBM Plex Mono", monospace; font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
.fileViewerEmpty { .fileViewerEmpty {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
min-height: 300px; min-height: 300px;
} }
@@ -1,89 +1,89 @@
.settingsLayout { .settingsLayout {
display: flex; display: flex;
gap: 2rem; gap: 2rem;
padding: 1.5rem 0; padding: 1.5rem 0;
} }
.settingsSidebar { .settingsSidebar {
width: 200px; width: 200px;
flex-shrink: 0; flex-shrink: 0;
} }
.settingsNav { .settingsNav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.settingsNavLink { .settingsNavLink {
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
border-radius: 8px; border-radius: 8px;
color: var(--muted); color: var(--muted);
text-decoration: none; text-decoration: none;
font-size: 0.95rem; font-size: 0.95rem;
transition: all 0.2s; transition: all 0.2s;
} }
.settingsNavLink:hover { .settingsNavLink:hover {
background: var(--bg); background: var(--bg);
color: var(--ink); color: var(--ink);
} }
.settingsNavLinkActive { .settingsNavLinkActive {
background: var(--brand); background: var(--brand);
color: white; color: white;
font-weight: 500; font-weight: 500;
} }
.settingsContent { .settingsContent {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.settingsPanel { .settingsPanel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 14px; border-radius: 14px;
padding: 1.5rem; padding: 1.5rem;
} }
.settingsBreadcrumb { .settingsBreadcrumb {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--muted); color: var(--muted);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.settingsBreadcrumb a { .settingsBreadcrumb a {
color: var(--brand); color: var(--brand);
text-decoration: none; text-decoration: none;
} }
.settingsBreadcrumb a:hover { .settingsBreadcrumb a:hover {
text-decoration: underline; text-decoration: underline;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.settingsLayout { .settingsLayout {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.settingsSidebar { .settingsSidebar {
width: 100%; width: 100%;
} }
.settingsNav { .settingsNav {
flex-direction: row; flex-direction: row;
overflow-x: auto; overflow-x: auto;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
} }
.settingsNavLink { .settingsNavLink {
white-space: nowrap; white-space: nowrap;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
} }
+4 -1
View File
@@ -223,7 +223,10 @@ export const InstanceList = ({
/> />
{instance.status} {instance.status}
{isTunnelUnhealthy(instance) && ( {isTunnelUnhealthy(instance) && (
<span className={styles.errorBadge} 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>
@@ -1,147 +1,147 @@
.shell { .shell {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.shellHeader { .shellHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0.85rem 1.25rem; padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 88%, transparent); background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(7px); backdrop-filter: blur(7px);
} }
.brand { .brand {
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.headerActions { .headerActions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
.shellBody { .shellBody {
display: grid; display: grid;
grid-template-columns: 230px 1fr; grid-template-columns: 230px 1fr;
min-height: calc(100vh - 57px); min-height: calc(100vh - 57px);
} }
.shellNav { .shellNav {
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 1rem 0.75rem; padding: 1rem 0.75rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
background: color-mix(in srgb, var(--panel) 65%, transparent); background: color-mix(in srgb, var(--panel) 65%, transparent);
} }
.navItem { .navItem {
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
border-radius: 10px; border-radius: 10px;
color: var(--muted); color: var(--muted);
} }
.navItem:hover { .navItem:hover {
background: #ece7df; background: #ece7df;
color: var(--ink); color: var(--ink);
} }
.navItemActive { .navItemActive {
background: var(--brand); background: var(--brand);
color: #f7fff7; color: #f7fff7;
} }
.navSectionTitle { .navSectionTitle {
margin-top: 0.5rem; margin-top: 0.5rem;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: var(--muted); color: var(--muted);
} }
.navDivider { .navDivider {
height: 1px; height: 1px;
background: var(--border); background: var(--border);
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.navBadge { .navBadge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 18px; min-width: 18px;
height: 18px; height: 18px;
padding: 0 5px; padding: 0 5px;
background: var(--primary); background: var(--primary);
color: var(--primary-fg); color: var(--primary-fg);
border-radius: 9px; border-radius: 9px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
margin-left: auto; margin-left: auto;
} }
.sessionItem { .sessionItem {
position: relative; position: relative;
padding-left: var(--space-6); padding-left: var(--space-6);
} }
.sessionStatus { .sessionStatus {
position: absolute; position: absolute;
left: var(--space-2); left: var(--space-2);
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--muted); background: var(--muted);
} }
.sessionStatus.running { .sessionStatus.running {
background: var(--success); background: var(--success);
} }
.sessionName { .sessionName {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 140px; max-width: 140px;
} }
.shellContent { .shellContent {
padding: 1.25rem; padding: 1.25rem;
overflow-x: hidden; overflow-x: hidden;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.shellBody { .shellBody {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
} }
.shellNav { .shellNav {
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.25rem; gap: 0.25rem;
padding: 0.5rem; padding: 0.5rem;
border-right: none; border-right: none;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.navItem { .navItem {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
white-space: nowrap; white-space: nowrap;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.shellContent { .shellContent {
padding: var(--space-4); padding: var(--space-4);
} }
} }
+32 -30
View File
@@ -3,42 +3,44 @@ import { Link, useLocation } from "react-router-dom";
import styles from "./features/settings/SettingsTabLayout.module.css"; import styles from "./features/settings/SettingsTabLayout.module.css";
interface Tab { interface Tab {
id: string; id: string;
label: string; label: string;
path: string; path: string;
} }
interface SettingsTabLayoutProps { interface SettingsTabLayoutProps {
tabs: Tab[]; tabs: Tab[];
children: React.ReactNode; children: React.ReactNode;
basePath: string; basePath: string;
} }
export const SettingsTabLayout: React.FC<SettingsTabLayoutProps> = ({ export const SettingsTabLayout: React.FC<SettingsTabLayoutProps> = ({
tabs, tabs,
children, children,
basePath, basePath,
}) => { }) => {
const location = useLocation(); const location = useLocation();
return ( return (
<div className={styles.settingsLayout}> <div className={styles.settingsLayout}>
<aside className={styles.settingsSidebar}> <aside className={styles.settingsSidebar}>
<nav className={styles.settingsNav}> <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={`${styles.settingsNavLink} ${ className={`${styles.settingsNavLink} ${
location.pathname.includes(tab.path) ? styles.settingsNavLinkActive : "" location.pathname.includes(tab.path)
}`} ? styles.settingsNavLinkActive
> : ""
{tab.label} }`}
</Link> >
))} {tab.label}
</nav> </Link>
</aside> ))}
<main className={styles.settingsContent}>{children}</main> </nav>
</div> </aside>
); <main className={styles.settingsContent}>{children}</main>
</div>
);
}; };
+19 -19
View File
@@ -1,51 +1,51 @@
.home-page { .home-page {
max-width: 1240px; max-width: 1240px;
} }
.home-hero { .home-hero {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: var(--space-4);
align-items: flex-start; align-items: flex-start;
} }
.home-hero-actions { .home-hero-actions {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
flex-wrap: wrap; flex-wrap: wrap;
} }
.home-summary-grid, .home-summary-grid,
.home-project-grid, .home-project-grid,
.home-session-grid { .home-session-grid {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
} }
.home-summary-grid { .home-summary-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
} }
.home-project-grid, .home-project-grid,
.home-session-grid { .home-session-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
} }
.home-section h2, .home-section h2,
.settings-header h1, .settings-header h1,
.settings-panel h2 { .settings-panel h2 {
margin: 0; margin: 0;
} }
.home-section h3, .home-section h3,
.home-section p { .home-section p {
margin: 0; margin: 0;
} }
.eyebrow { .eyebrow {
margin: 0; margin: 0;
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: var(--muted); color: var(--muted);
} }
+143 -143
View File
@@ -1,255 +1,255 @@
.history-actions { .history-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;
} }
.branch-selector { .branch-selector {
padding: 0.45rem 0.7rem; padding: 0.45rem 0.7rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
font: inherit; font: inherit;
background: var(--panel); background: var(--panel);
color: var(--ink); color: var(--ink);
} }
.history-container { .history-container {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 1rem; gap: 1rem;
min-height: 60vh; min-height: 60vh;
} }
.commit-list { .commit-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
overflow-y: auto; overflow-y: auto;
max-height: 70vh; max-height: 70vh;
} }
.commit-list.with-detail { .commit-list.with-detail {
grid-column: 1; grid-column: 1;
} }
.commit-item { .commit-item {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem; padding: 0.75rem;
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
} }
.commit-item:hover { .commit-item:hover {
background: #ece7df; background: #ece7df;
} }
.commit-item.selected { .commit-item.selected {
border-color: var(--brand); border-color: var(--brand);
background: #f0f7f4; background: #f0f7f4;
} }
.commit-graph { .commit-graph {
font-family: monospace; font-family: monospace;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--brand); color: var(--brand);
white-space: pre; white-space: pre;
flex-shrink: 0; flex-shrink: 0;
min-width: 60px; min-width: 60px;
} }
.graph-line { .graph-line {
display: inline-block; display: inline-block;
} }
.commit-content { .commit-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.commit-header { .commit-header {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
} }
.commit-hash { .commit-hash {
font-family: monospace; font-family: monospace;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--brand); color: var(--brand);
background: #f0f7f4; background: #f0f7f4;
padding: 0.15rem 0.4rem; padding: 0.15rem 0.4rem;
border-radius: 6px; border-radius: 6px;
} }
.commit-refs { .commit-refs {
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.ref-tag { .ref-tag {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.15rem 0.4rem; padding: 0.15rem 0.4rem;
background: var(--brand); background: var(--brand);
color: white; color: white;
border-radius: 999px; border-radius: 999px;
} }
.commit-message { .commit-message {
margin: 0 0 0.35rem; margin: 0 0 0.35rem;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.commit-meta { .commit-meta {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--muted); color: var(--muted);
} }
.commit-detail-panel { .commit-detail-panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 14px; border-radius: 14px;
padding: 1.25rem; padding: 1.25rem;
overflow-y: auto; overflow-y: auto;
max-height: 70vh; max-height: 70vh;
} }
.detail-header { .detail-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.detail-header h3 { .detail-header h3 {
margin: 0; margin: 0;
} }
.detail-content { .detail-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 1.25rem;
} }
.detail-section { .detail-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
} }
.detail-section h4 { .detail-section h4 {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
color: var(--muted); color: var(--muted);
font-size: 0.9rem; font-size: 0.9rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.commit-hash-full { .commit-hash-full {
font-family: monospace; font-family: monospace;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--brand); color: var(--brand);
margin: 0; margin: 0;
} }
.commit-message-full { .commit-message-full {
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
line-height: 1.5; line-height: 1.5;
white-space: pre-wrap; white-space: pre-wrap;
} }
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 0.75rem; gap: 0.75rem;
} }
.stat { .stat {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 0.75rem; padding: 0.75rem;
background: #f5f3ee; background: #f5f3ee;
border-radius: 10px; border-radius: 10px;
} }
.stat.additions { .stat.additions {
background: #f0fdf4; background: #f0fdf4;
} }
.stat.deletions { .stat.deletions {
background: #fef2f2; background: #fef2f2;
} }
.stat-value { .stat-value {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
color: var(--ink); color: var(--ink);
} }
.stat.additions .stat-value { .stat.additions .stat-value {
color: #16a34a; color: #16a34a;
} }
.stat.deletions .stat-value { .stat.deletions .stat-value {
color: #dc2626; color: #dc2626;
} }
.stat-label { .stat-label {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--muted); color: var(--muted);
} }
.parent-list { .parent-list {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.parent-hash { .parent-hash {
font-family: monospace; font-family: monospace;
font-size: 0.85rem; font-size: 0.85rem;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
background: #f5f3ee; background: #f5f3ee;
border-radius: 6px; border-radius: 6px;
} }
.diff-content { .diff-content {
font-family: monospace; font-family: monospace;
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.5; line-height: 1.5;
background: #f5f3ee; background: #f5f3ee;
padding: 0.75rem; padding: 0.75rem;
border-radius: 10px; border-radius: 10px;
overflow-x: auto; overflow-x: auto;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.history-container { .history-container {
grid-template-columns: 1fr 400px; grid-template-columns: 1fr 400px;
} }
.commit-list.with-detail { .commit-list.with-detail {
grid-column: 1; grid-column: 1;
} }
.commit-detail-panel { .commit-detail-panel {
grid-column: 2; grid-column: 2;
position: sticky; position: sticky;
top: 1rem; top: 1rem;
} }
} }
+16 -16
View File
@@ -1,33 +1,33 @@
.project-list { .project-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
} }
.project-card { .project-card {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: 1rem; gap: 1rem;
} }
.project-info h3 { .project-info h3 {
margin: 0 0 0.35rem; margin: 0 0 0.35rem;
} }
.project-info p { .project-info p {
margin: 0; margin: 0;
} }
.project-actions { .project-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
} }
.delete-confirm { .delete-confirm {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
} }
+83 -83
View File
@@ -1,142 +1,142 @@
.repo-workspace { .repo-workspace {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 60px); height: calc(100vh - 60px);
overflow: hidden; overflow: hidden;
} }
.workspace-header { .workspace-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--panel); background: var(--panel);
} }
.workspace-header-left { .workspace-header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
.workspace-header-icon { .workspace-header-icon {
font-size: 1.5rem; font-size: 1.5rem;
} }
.workspace-header-info { .workspace-header-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.workspace-header-title { .workspace-header-title {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
} }
.workspace-header-subtitle { .workspace-header-subtitle {
color: var(--muted); color: var(--muted);
font-size: 0.875rem; font-size: 0.875rem;
} }
.workspace-header-actions { .workspace-header-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }
.workspace-header-action-btn { .workspace-header-action-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
background: var(--panel); background: var(--panel);
color: var(--ink); color: var(--ink);
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.workspace-header-action-btn:hover { .workspace-header-action-btn:hover {
background: var(--bg); background: var(--bg);
border-color: var(--brand); border-color: var(--brand);
} }
.workspace-title { .workspace-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
} }
.workspace-title h1 { .workspace-title h1 {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
} }
.repo-name { .repo-name {
color: var(--muted); color: var(--muted);
font-size: 0.875rem; font-size: 0.875rem;
} }
.workspace-layout { .workspace-layout {
display: flex; display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
} }
.workspace-sidebar { .workspace-sidebar {
width: 280px; width: 280px;
min-width: 280px; min-width: 280px;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
background: var(--panel); background: var(--panel);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.sidebar-section { .sidebar-section {
padding: 1rem; padding: 1rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.sidebar-section label { .sidebar-section label {
margin: 0; margin: 0;
} }
.workspace-main { .workspace-main {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
padding: 1rem; padding: 1rem;
background: var(--bg); background: var(--bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.workspace-layout { .workspace-layout {
flex-direction: column; flex-direction: column;
} }
.workspace-sidebar { .workspace-sidebar {
width: 100%; width: 100%;
min-width: auto; min-width: auto;
max-height: 40vh; max-height: 40vh;
border-right: none; border-right: none;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.workspace-header { .workspace-header {
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-3);
align-items: flex-start; align-items: flex-start;
padding: var(--space-4); padding: var(--space-4);
} }
.workspace-header-actions { .workspace-header-actions {
width: 100%; width: 100%;
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
+86 -86
View File
@@ -1,176 +1,176 @@
.sessions-page { .sessions-page {
max-width: 1200px; max-width: 1200px;
} }
.last-session-section { .last-session-section {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.last-session-card { .last-session-card {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--space-4); gap: var(--space-4);
padding: var(--space-5); padding: var(--space-5);
border: 2px solid var(--primary); border: 2px solid var(--primary);
} }
.last-session-info h3 { .last-session-info h3 {
margin: 0 0 var(--space-1) 0; margin: 0 0 var(--space-1) 0;
font-size: 1.25rem; font-size: 1.25rem;
} }
.active-sessions-section { .active-sessions-section {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.active-sessions-section h2 { .active-sessions-section h2 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
} }
.active-sessions-section .badge { .active-sessions-section .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 24px; min-width: 24px;
height: 24px; height: 24px;
padding: 0 6px; padding: 0 6px;
background: var(--success); background: var(--success);
color: white; color: white;
border-radius: 12px; border-radius: 12px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
.sessions-grid { .sessions-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-4); gap: var(--space-4);
} }
.session-card { .session-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-4); padding: var(--space-4);
} }
.session-info h4 { .session-info h4 {
margin: 0 0 var(--space-1) 0; margin: 0 0 var(--space-1) 0;
font-size: 1rem; font-size: 1rem;
} }
.session-url { .session-url {
margin: var(--space-1) 0; margin: var(--space-1) 0;
font-size: 0.8rem; font-size: 0.8rem;
word-break: break-all; word-break: break-all;
} }
.session-url a { .session-url a {
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
} }
.session-url a:hover { .session-url a:hover {
text-decoration: underline; text-decoration: underline;
} }
.session-actions { .session-actions {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
} }
.recent-sessions-section { .recent-sessions-section {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.recent-sessions-list { .recent-sessions-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
} }
.recent-session-item { .recent-session-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
} }
.recent-session-info { .recent-session-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-1); gap: var(--space-1);
} }
.recent-session-name { .recent-session-name {
font-weight: 500; font-weight: 500;
} }
.recent-session-actions { .recent-session-actions {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
align-items: center; align-items: center;
} }
.delete-confirm-inline, .delete-confirm-inline,
.stop-confirm-inline { .stop-confirm-inline {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
align-items: center; align-items: center;
} }
.confirm-text { .confirm-text {
font-size: 13px; font-size: 13px;
color: var(--muted); color: var(--muted);
font-weight: 500; font-weight: 500;
} }
.create-session-section { .create-session-section {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.create-session-form { .create-session-form {
max-width: 600px; max-width: 600px;
} }
.create-session-form .form-row { .create-session-form .form-row {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-4); gap: var(--space-4);
} }
.status-badge { .status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
text-transform: capitalize; text-transform: capitalize;
} }
.status-badge.running { .status-badge.running {
background: var(--success-light, #dcfce7); background: var(--success-light, #dcfce7);
color: var(--success, #16a34a); color: var(--success, #16a34a);
} }
.status-badge.stopped { .status-badge.stopped {
background: var(--muted-bg, #f3f4f6); background: var(--muted-bg, #f3f4f6);
color: var(--muted, #6b7280); color: var(--muted, #6b7280);
} }
.status-badge.pending { .status-badge.pending {
background: var(--warning-light, #fef3c7); background: var(--warning-light, #fef3c7);
color: var(--warning, #d97706); color: var(--warning, #d97706);
} }
.status-badge.error { .status-badge.error {
background: var(--danger-light, #fee2e2); background: var(--danger-light, #fee2e2);
color: var(--danger, #dc2626); color: var(--danger, #dc2626);
} }
+18 -18
View File
@@ -1,40 +1,40 @@
.settings-page { .settings-page {
max-width: 1240px; max-width: 1240px;
} }
.settings-header h1, .settings-header h1,
.settings-panel h2 { .settings-panel h2 {
margin: 0; margin: 0;
} }
.settings-tabs { .settings-tabs {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
flex-wrap: wrap; flex-wrap: wrap;
} }
.settings-tab { .settings-tab {
padding: 0.6rem 0.9rem; padding: 0.6rem 0.9rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--muted); color: var(--muted);
background: var(--panel); background: var(--panel);
} }
.settings-tab.active { .settings-tab.active {
background: var(--brand); background: var(--brand);
color: white; color: white;
border-color: transparent; border-color: transparent;
} }
.settings-actions, .settings-actions,
.form-actions { .form-actions {
display: flex; display: flex;
gap: var(--space-3); gap: var(--space-3);
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
} }
.project-settings-page { .project-settings-page {
padding: 1.5rem; padding: 1.5rem;
} }
+37 -37
View File
@@ -1,66 +1,66 @@
.keys-list { .keys-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
} }
.key-card { .key-card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 14px; border-radius: 14px;
padding: 1rem; padding: 1rem;
} }
.key-header { .key-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.key-header h3 { .key-header h3 {
margin: 0; margin: 0;
} }
.key-meta { .key-meta {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.key-public { .key-public {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.5rem; padding: 0.5rem;
background: #f5f3ee; background: #f5f3ee;
border-radius: 8px; border-radius: 8px;
} }
.key-public code { .key-public code {
font-size: 0.85rem; font-size: 0.85rem;
word-break: break-all; word-break: break-all;
flex: 1; flex: 1;
} }
.ssh-key-list { .ssh-key-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-3);
} }
.ssh-key-item { .ssh-key-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-4); padding: var(--space-4);
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.ssh-key-item { .ssh-key-item {
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
} }