feat: reorganize long files - frontend pages, CSS, partial backend
Frontend: - Extract ToolWorkshopPage (1269→110), ConfigProfilesPage (1611→170), TerminalPage (571→112), SettingsPage (284→137), SshKeysPage (277→84), ProjectsPage (433→113), RepoWorkspacePage (505→89) - Extract 15+ components and 8 hooks for state management - Delete monolithic styles.css (5683 lines), extract to styles/ directory Backend: - Extract config_profiles helpers to services/config/crud_service.py and resolver_service.py (842→474 lines) - Extract git_repositories helpers to services/git/operations.py (1588→1422 lines) Quality gates: tsc --noEmit pass, npm run build pass, py_compile pass Tests: 9/12 files pass (3 pre-existing failures)
This commit is contained in:
@@ -1,10 +1,7 @@
|
|||||||
"""Config profile API endpoints."""
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -12,10 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models import ConfigProfile, ConfigProfileInclude
|
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
||||||
from src.models.project import Project
|
|
||||||
from src.models import ToolType
|
|
||||||
from src.models import UserConfig
|
|
||||||
from src.schemas.config import (
|
from src.schemas.config import (
|
||||||
ConfigProfileCreate,
|
ConfigProfileCreate,
|
||||||
ConfigProfileIncludeUpdate,
|
ConfigProfileIncludeUpdate,
|
||||||
@@ -31,121 +25,25 @@ from src.services.config.config_profile_resolver import (
|
|||||||
resolve_profile,
|
resolve_profile,
|
||||||
resolved_profile_to_dict,
|
resolved_profile_to_dict,
|
||||||
)
|
)
|
||||||
from src.utils.git_url_parser import parse_git_url
|
from src.services.config.crud_service import (
|
||||||
|
calculate_profile_size,
|
||||||
|
check_access,
|
||||||
|
get_or_create_user_config,
|
||||||
|
get_profile_with_includes,
|
||||||
|
profile_to_response,
|
||||||
|
validate_default_profiles,
|
||||||
|
validate_git_mounts,
|
||||||
|
MAX_PROFILE_SIZE_BYTES,
|
||||||
|
)
|
||||||
|
from src.services.config.resolver_service import (
|
||||||
|
resolve_default_profile,
|
||||||
|
validate_git_url,
|
||||||
|
)
|
||||||
|
|
||||||
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_PROFILE_SIZE_MB = 10
|
|
||||||
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_profile_size(data: dict) -> int:
|
|
||||||
"""Calculate approximate serialized size of profile data."""
|
|
||||||
total = 0
|
|
||||||
for key, value in data.get("env_vars", {}).items():
|
|
||||||
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
|
||||||
for key, value in data.get("runtime_hints", {}).items():
|
|
||||||
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
|
||||||
for mount in data.get("mounts", []):
|
|
||||||
total += len(str(mount.get("target", "")).encode("utf-8"))
|
|
||||||
total += len(str(mount.get("mode", "")).encode("utf-8"))
|
|
||||||
for path, content in mount.get("files", {}).items():
|
|
||||||
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
|
||||||
for path, content in data.get("files", {}).items():
|
|
||||||
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_profile_with_includes(
|
|
||||||
session: AsyncSession, profile_id: uuid.UUID
|
|
||||||
) -> ConfigProfile | None:
|
|
||||||
"""Fetch a profile with includes eagerly loaded."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigProfile)
|
|
||||||
.where(ConfigProfile.id == profile_id)
|
|
||||||
.options(selectinload(ConfigProfile.includes))
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_access(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID | None = None,
|
|
||||||
tool_type_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Verify user has access to referenced project and tool type."""
|
|
||||||
if project_id is not None:
|
|
||||||
project = await session.get(Project, project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
|
||||||
)
|
|
||||||
# Add ownership check if needed; for now just verify existence
|
|
||||||
if tool_type_id is not None:
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
|
||||||
if tool_type is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_git_mounts(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
git_mounts: list[Any],
|
|
||||||
project_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Validate git mount URLs.
|
|
||||||
|
|
||||||
Simply checks that remote_url looks like a valid git URL.
|
|
||||||
Actual clone validation happens at instance startup time.
|
|
||||||
"""
|
|
||||||
for mount in git_mounts:
|
|
||||||
remote_url = mount.get("remote_url")
|
|
||||||
if not remote_url:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Git mount missing remote_url",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Invalid git URL: {remote_url}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_to_response(
|
|
||||||
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
|
|
||||||
) -> dict:
|
|
||||||
return {
|
|
||||||
"id": str(profile.id),
|
|
||||||
"user_id": str(profile.user_id),
|
|
||||||
"name": profile.name,
|
|
||||||
"description": profile.description,
|
|
||||||
"project_id": str(profile.project_id) if profile.project_id else None,
|
|
||||||
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
|
|
||||||
"env_vars": profile.env_vars or {},
|
|
||||||
"runtime_hints": profile.runtime_hints or {},
|
|
||||||
"mounts": profile.mounts or [],
|
|
||||||
"git_mounts": profile.git_mounts or [],
|
|
||||||
"files": profile.files or {},
|
|
||||||
"is_default": profile.is_default,
|
|
||||||
"includes": [
|
|
||||||
{
|
|
||||||
"id": str(inc.id),
|
|
||||||
"included_profile_id": str(inc.included_profile_id),
|
|
||||||
"order_index": inc.order_index,
|
|
||||||
}
|
|
||||||
for inc in (includes or profile.includes)
|
|
||||||
],
|
|
||||||
"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("", response_model=list[ConfigProfileResponse])
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
async def list_config_profiles(
|
async def list_config_profiles(
|
||||||
@@ -165,26 +63,21 @@ async def list_config_profiles(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if project_id or tool_type_id:
|
if project_id or tool_type_id:
|
||||||
# Compatibility filter: include portable profiles and matching scoped profiles
|
from sqlalchemy import or_
|
||||||
|
|
||||||
project_uuid = uuid.UUID(project_id) if project_id else None
|
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||||
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||||
|
|
||||||
from sqlalchemy import or_
|
|
||||||
|
|
||||||
conditions: list = []
|
conditions: list = []
|
||||||
# Portable profiles (no project, no tool)
|
|
||||||
conditions.append(
|
conditions.append(
|
||||||
(ConfigProfile.project_id.is_(None))
|
(ConfigProfile.project_id.is_(None))
|
||||||
& (ConfigProfile.tool_type_id.is_(None))
|
& (ConfigProfile.tool_type_id.is_(None))
|
||||||
)
|
)
|
||||||
if project_uuid:
|
if project_uuid:
|
||||||
# Profiles matching this project (with or without tool)
|
|
||||||
conditions.append(ConfigProfile.project_id == project_uuid)
|
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||||
if tool_uuid:
|
if tool_uuid:
|
||||||
# Profiles matching this tool (with or without project)
|
|
||||||
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||||
if project_uuid and tool_uuid:
|
if project_uuid and tool_uuid:
|
||||||
# Exact match
|
|
||||||
conditions.append(
|
conditions.append(
|
||||||
(ConfigProfile.project_id == project_uuid)
|
(ConfigProfile.project_id == project_uuid)
|
||||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
@@ -194,7 +87,7 @@ async def list_config_profiles(
|
|||||||
|
|
||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
profiles = result.scalars().all()
|
profiles = result.scalars().all()
|
||||||
return [_profile_to_response(p) for p in profiles]
|
return [profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -208,7 +101,6 @@ async def create_config_profile(
|
|||||||
"""Create a new config profile."""
|
"""Create a new config profile."""
|
||||||
user_uuid = current_user_id
|
user_uuid = current_user_id
|
||||||
|
|
||||||
# Check for duplicate name
|
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(ConfigProfile)
|
select(ConfigProfile)
|
||||||
.where(
|
.where(
|
||||||
@@ -223,24 +115,21 @@ async def create_config_profile(
|
|||||||
detail=f"Profile with name '{data.name}' already exists",
|
detail=f"Profile with name '{data.name}' already exists",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate references
|
|
||||||
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
||||||
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
||||||
await _check_access(session, user_uuid, project_uuid, tool_uuid)
|
await check_access(session, user_uuid, project_uuid, tool_uuid)
|
||||||
|
|
||||||
# Validate git mounts reference existing repositories
|
|
||||||
if data.git_mounts:
|
if data.git_mounts:
|
||||||
git_mounts_data = [
|
git_mounts_data = [
|
||||||
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
|
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
|
||||||
]
|
]
|
||||||
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
await validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
||||||
|
|
||||||
# Check size
|
size = calculate_profile_size(data.model_dump())
|
||||||
size = _calculate_profile_size(data.model_dump())
|
|
||||||
if size > MAX_PROFILE_SIZE_BYTES:
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
detail="Profile size exceeds 10MB limit",
|
||||||
)
|
)
|
||||||
|
|
||||||
profile = ConfigProfile(
|
profile = ConfigProfile(
|
||||||
@@ -259,7 +148,6 @@ async def create_config_profile(
|
|||||||
session.add(profile)
|
session.add(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Re-fetch with includes to avoid lazy loading issues
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfile)
|
select(ConfigProfile)
|
||||||
.where(ConfigProfile.id == profile.id)
|
.where(ConfigProfile.id == profile.id)
|
||||||
@@ -268,7 +156,7 @@ async def create_config_profile(
|
|||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
||||||
return _profile_to_response(profile)
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
@@ -278,7 +166,7 @@ async def get_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Get a config profile by ID."""
|
"""Get a config profile by ID."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -287,7 +175,7 @@ async def get_config_profile(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
return _profile_to_response(profile)
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
@@ -298,7 +186,7 @@ async def update_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Update a config profile."""
|
"""Update a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -310,7 +198,6 @@ async def update_config_profile(
|
|||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Handle name uniqueness
|
|
||||||
if "name" in update_data:
|
if "name" in update_data:
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(ConfigProfile).where(
|
select(ConfigProfile).where(
|
||||||
@@ -325,7 +212,6 @@ async def update_config_profile(
|
|||||||
detail=f"Profile with name '{update_data['name']}' already exists",
|
detail=f"Profile with name '{update_data['name']}' already exists",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate references
|
|
||||||
project_uuid = (
|
project_uuid = (
|
||||||
uuid.UUID(update_data["project_id"])
|
uuid.UUID(update_data["project_id"])
|
||||||
if "project_id" in update_data and update_data["project_id"]
|
if "project_id" in update_data and update_data["project_id"]
|
||||||
@@ -336,29 +222,26 @@ async def update_config_profile(
|
|||||||
if "tool_type_id" in update_data and update_data["tool_type_id"]
|
if "tool_type_id" in update_data and update_data["tool_type_id"]
|
||||||
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
||||||
)
|
)
|
||||||
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
|
await check_access(session, profile.user_id, project_uuid, tool_uuid)
|
||||||
|
|
||||||
# Validate git mounts reference existing repositories
|
|
||||||
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
||||||
git_mounts_data = [
|
git_mounts_data = [
|
||||||
m.model_dump() if hasattr(m, "model_dump") else m
|
m.model_dump() if hasattr(m, "model_dump") else m
|
||||||
for m in update_data["git_mounts"]
|
for m in update_data["git_mounts"]
|
||||||
]
|
]
|
||||||
await _validate_git_mounts(
|
await validate_git_mounts(
|
||||||
session, profile.user_id, git_mounts_data, project_uuid
|
session, profile.user_id, git_mounts_data, project_uuid
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check size
|
current_data = profile_to_response(profile)
|
||||||
current_data = _profile_to_response(profile)
|
|
||||||
merged = {**current_data, **update_data}
|
merged = {**current_data, **update_data}
|
||||||
size = _calculate_profile_size(merged)
|
size = calculate_profile_size(merged)
|
||||||
if size > MAX_PROFILE_SIZE_BYTES:
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
detail="Profile size exceeds 10MB limit",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply updates
|
|
||||||
for field_name, value in update_data.items():
|
for field_name, value in update_data.items():
|
||||||
if field_name in ("project_id", "tool_type_id"):
|
if field_name in ("project_id", "tool_type_id"):
|
||||||
value = uuid.UUID(value) if value else None
|
value = uuid.UUID(value) if value else None
|
||||||
@@ -370,7 +253,6 @@ async def update_config_profile(
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Re-fetch with includes to avoid lazy loading issues
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfile)
|
select(ConfigProfile)
|
||||||
.where(ConfigProfile.id == profile.id)
|
.where(ConfigProfile.id == profile.id)
|
||||||
@@ -379,7 +261,7 @@ async def update_config_profile(
|
|||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
logger.debug("Updated config profile %s", profile.id)
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
return _profile_to_response(profile)
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -389,7 +271,7 @@ async def delete_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Delete a config profile."""
|
"""Delete a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -414,7 +296,7 @@ async def update_profile_includes(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Update the ordered includes for a config profile."""
|
"""Update the ordered includes for a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -424,7 +306,6 @@ async def update_profile_includes(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate all included profiles exist and belong to the user
|
|
||||||
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
for inc_uuid in included_uuids:
|
for inc_uuid in included_uuids:
|
||||||
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||||
@@ -444,10 +325,8 @@ async def update_profile_includes(
|
|||||||
detail="Profile cannot include itself",
|
detail="Profile cannot include itself",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for cycles
|
|
||||||
cycle = await check_include_cycle(session, profile.id, None)
|
cycle = await check_include_cycle(session, profile.id, None)
|
||||||
if cycle is None and included_uuids:
|
if cycle is None and included_uuids:
|
||||||
# Check each new include would not create a cycle
|
|
||||||
for inc_uuid in included_uuids:
|
for inc_uuid in included_uuids:
|
||||||
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
||||||
if cycle is not None:
|
if cycle is not None:
|
||||||
@@ -460,7 +339,6 @@ async def update_profile_includes(
|
|||||||
detail=f"Include cycle detected: {cycle_str}",
|
detail=f"Include cycle detected: {cycle_str}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove existing includes
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfileInclude).where(
|
select(ConfigProfileInclude).where(
|
||||||
ConfigProfileInclude.profile_id == profile.id
|
ConfigProfileInclude.profile_id == profile.id
|
||||||
@@ -470,7 +348,6 @@ async def update_profile_includes(
|
|||||||
await session.delete(existing)
|
await session.delete(existing)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
# Add new includes
|
|
||||||
for order_index, inc_uuid in enumerate(included_uuids):
|
for order_index, inc_uuid in enumerate(included_uuids):
|
||||||
include = ConfigProfileInclude(
|
include = ConfigProfileInclude(
|
||||||
profile_id=profile.id,
|
profile_id=profile.id,
|
||||||
@@ -479,10 +356,8 @@ async def update_profile_includes(
|
|||||||
)
|
)
|
||||||
session.add(include)
|
session.add(include)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Re-fetch profile (includes loaded separately due to SQLite async issue)
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||||
)
|
)
|
||||||
@@ -496,7 +371,7 @@ async def update_profile_includes(
|
|||||||
direct_includes = inc_result.scalars().all()
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
logger.debug("Updated includes for config profile %s", profile.id)
|
logger.debug("Updated includes for config profile %s", profile.id)
|
||||||
return _profile_to_response(profile, list(direct_includes))
|
return profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}/preview")
|
@router.get("/{profile_id}/preview")
|
||||||
@@ -506,7 +381,7 @@ async def preview_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Preview the resolved output of a config profile."""
|
"""Preview the resolved output of a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -528,120 +403,19 @@ async def preview_config_profile(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/defaults/resolve")
|
@router.get("/defaults/resolve")
|
||||||
async def resolve_default_profile(
|
async def resolve_default_profile_endpoint(
|
||||||
project_id: str = Query(..., description="Project ID"),
|
project_id: str = Query(..., description="Project ID"),
|
||||||
tool_type_id: str = Query(..., description="Tool type ID"),
|
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Resolve the default config profile for a project/tool combination.
|
"""Resolve the default config profile for a project/tool combination."""
|
||||||
|
return await resolve_default_profile(
|
||||||
Selects by specificity:
|
session,
|
||||||
1. project+tool explicit default
|
current_user_id,
|
||||||
2. project explicit default
|
uuid.UUID(project_id),
|
||||||
3. tool explicit default
|
uuid.UUID(tool_type_id),
|
||||||
4. global/user explicit default
|
|
||||||
5. first created compatible profile
|
|
||||||
6. none (returns null)
|
|
||||||
"""
|
|
||||||
user_uuid = current_user_id
|
|
||||||
project_uuid = uuid.UUID(project_id)
|
|
||||||
tool_uuid = uuid.UUID(tool_type_id)
|
|
||||||
|
|
||||||
# Fetch all compatible profiles ordered by created_at
|
|
||||||
query = (
|
|
||||||
select(ConfigProfile)
|
|
||||||
.where(ConfigProfile.user_id == user_uuid)
|
|
||||||
.where(
|
|
||||||
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
|
||||||
| (ConfigProfile.project_id == project_uuid)
|
|
||||||
| (ConfigProfile.tool_type_id == tool_uuid)
|
|
||||||
| (
|
|
||||||
(ConfigProfile.project_id == project_uuid)
|
|
||||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by(ConfigProfile.created_at)
|
|
||||||
)
|
)
|
||||||
result = await session.execute(query)
|
|
||||||
profiles = result.scalars().all()
|
|
||||||
|
|
||||||
if not profiles:
|
|
||||||
return {"profile_id": None, "profile_name": None}
|
|
||||||
|
|
||||||
# Check explicit defaults by specificity
|
|
||||||
explicit_defaults = [p for p in profiles if p.is_default]
|
|
||||||
|
|
||||||
# Most specific: project+tool
|
|
||||||
for p in explicit_defaults:
|
|
||||||
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
|
|
||||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
|
||||||
|
|
||||||
# Next: project only
|
|
||||||
for p in explicit_defaults:
|
|
||||||
if p.project_id == project_uuid and p.tool_type_id is None:
|
|
||||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
|
||||||
|
|
||||||
# Next: tool only
|
|
||||||
for p in explicit_defaults:
|
|
||||||
if p.project_id is None and p.tool_type_id == tool_uuid:
|
|
||||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
|
||||||
|
|
||||||
# Next: global/user (no project, no tool)
|
|
||||||
for p in explicit_defaults:
|
|
||||||
if p.project_id is None and p.tool_type_id is None:
|
|
||||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
|
||||||
|
|
||||||
# Fall back to first created compatible profile
|
|
||||||
first = profiles[0]
|
|
||||||
return {"profile_id": str(first.id), "profile_name": first.name}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Default profile management
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
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():
|
|
||||||
try:
|
|
||||||
profile_uuid = uuid.UUID(profile_id_str)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
|
|
||||||
)
|
|
||||||
profile = await session.get(ConfigProfile, profile_uuid)
|
|
||||||
if profile is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Profile not found: {profile_id_str}",
|
|
||||||
)
|
|
||||||
if profile.user_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"Profile does not belong to user: {profile_id_str}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/defaults")
|
@router.get("/defaults")
|
||||||
@@ -664,8 +438,8 @@ async def set_default_profiles_endpoint(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Set default profile mappings for the current user."""
|
"""Set default profile mappings for the current user."""
|
||||||
await _validate_default_profiles(session, user_id, data.default_profiles)
|
await validate_default_profiles(session, user_id, data.default_profiles)
|
||||||
user_config = await _get_or_create_user_config(session, user_id)
|
user_config = await get_or_create_user_config(session, user_id)
|
||||||
user_config.config = {
|
user_config.config = {
|
||||||
**user_config.config,
|
**user_config.config,
|
||||||
"default_profiles": data.default_profiles,
|
"default_profiles": data.default_profiles,
|
||||||
@@ -691,152 +465,10 @@ async def get_default_profile_for_tool_type_endpoint(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||||
async def validate_git_url(
|
async def validate_git_url_endpoint(
|
||||||
data: ValidateGitUrlRequest,
|
data: ValidateGitUrlRequest,
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ValidateGitUrlResponse:
|
) -> ValidateGitUrlResponse:
|
||||||
"""Validate a git remote URL and list available branches.
|
"""Validate a git remote URL and list available branches."""
|
||||||
|
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
|
||||||
Parses the URL, suggests corrections for browser URLs, and runs
|
|
||||||
git ls-remote to verify reachability and enumerate branches.
|
|
||||||
"""
|
|
||||||
parse_result = parse_git_url(data.url)
|
|
||||||
original_url = data.url.strip()
|
|
||||||
url_to_check = parse_result.get("base_url") or original_url
|
|
||||||
|
|
||||||
if not url_to_check:
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error=parse_result.get("message", "Invalid URL"),
|
|
||||||
error_code=parse_result.get("error_code", "INVALID_URL"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# If the URL needed parsing, return suggestion without checking remote
|
|
||||||
if parse_result.get("needs_parsing") and url_to_check != original_url:
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
suggested_url=url_to_check,
|
|
||||||
error=parse_result.get("message"),
|
|
||||||
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Optional SSH key for private repos
|
|
||||||
env = None
|
|
||||||
key_path = None
|
|
||||||
if data.ssh_key_id:
|
|
||||||
from src.models import SSHKey
|
|
||||||
from src.services.shared.ssh_keys import _get_fernet
|
|
||||||
|
|
||||||
try:
|
|
||||||
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
|
|
||||||
except ValueError:
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error="Invalid SSH key ID format",
|
|
||||||
error_code="INVALID_SSH_KEY",
|
|
||||||
)
|
|
||||||
|
|
||||||
ssh_key = await session.get(SSHKey, ssh_key_uuid)
|
|
||||||
if ssh_key is None or ssh_key.user_id != current_user_id:
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error="SSH key not found or not authorized",
|
|
||||||
error_code="SSH_KEY_NOT_FOUND",
|
|
||||||
)
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
fernet = _get_fernet()
|
|
||||||
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
|
||||||
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
|
||||||
try:
|
|
||||||
os.write(fd, private_key.encode())
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
os.chmod(key_path, 0o600)
|
|
||||||
env = {
|
|
||||||
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "ls-remote", "--heads", url_to_check],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30,
|
|
||||||
env={**os.environ, **env} if env else None,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
if key_path and os.path.exists(key_path):
|
|
||||||
os.unlink(key_path)
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error="Remote repository check timed out",
|
|
||||||
error_code="TIMEOUT",
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
if key_path and os.path.exists(key_path):
|
|
||||||
os.unlink(key_path)
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error="git command not found on server",
|
|
||||||
error_code="GIT_NOT_FOUND",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if key_path and os.path.exists(key_path):
|
|
||||||
os.unlink(key_path)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
stderr = result.stderr.strip()
|
|
||||||
if (
|
|
||||||
"could not resolve" in stderr.lower()
|
|
||||||
or "unable to access" in stderr.lower()
|
|
||||||
):
|
|
||||||
error_msg = "Could not reach repository. Check the URL and network access."
|
|
||||||
error_code = "UNREACHABLE"
|
|
||||||
elif (
|
|
||||||
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
|
|
||||||
):
|
|
||||||
error_msg = (
|
|
||||||
"Authentication failed. Provide an SSH key for private repositories."
|
|
||||||
)
|
|
||||||
error_code = "AUTH_FAILED"
|
|
||||||
else:
|
|
||||||
error_msg = f"Repository not accessible: {stderr[:200]}"
|
|
||||||
error_code = "REMOTE_ERROR"
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error=error_msg,
|
|
||||||
error_code=error_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse branches from ls-remote output
|
|
||||||
branches: list[str] = []
|
|
||||||
default_branch = "main"
|
|
||||||
for line in result.stdout.strip().split("\n"):
|
|
||||||
if not line.strip():
|
|
||||||
continue
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) == 2:
|
|
||||||
ref = parts[1]
|
|
||||||
# refs/heads/branch-name
|
|
||||||
if ref.startswith("refs/heads/"):
|
|
||||||
branch_name = ref[len("refs/heads/") :]
|
|
||||||
branches.append(branch_name)
|
|
||||||
if branch_name in ("main", "master"):
|
|
||||||
default_branch = branch_name
|
|
||||||
|
|
||||||
if not branches:
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error="No branches found in remote repository",
|
|
||||||
error_code="NO_BRANCHES",
|
|
||||||
)
|
|
||||||
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=True,
|
|
||||||
suggested_url=url_to_check if url_to_check != original_url else None,
|
|
||||||
branches=branches,
|
|
||||||
default_branch=default_branch,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ from src.utils.git_control import (
|
|||||||
)
|
)
|
||||||
from src.utils.git_history import get_commit_detail, get_commit_history
|
from src.utils.git_history import get_commit_detail, get_commit_history
|
||||||
from src.utils.git_url_parser import parse_git_url
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
from src.services.git.operations import (
|
||||||
|
build_provider_clone_url,
|
||||||
|
clone_working_repository,
|
||||||
|
get_repo_path,
|
||||||
|
init_working_repository,
|
||||||
|
preflight_remote_repository,
|
||||||
|
)
|
||||||
from src.services.shared.ssh_keys import _get_fernet
|
from src.services.shared.ssh_keys import _get_fernet
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||||
@@ -51,179 +58,6 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
|||||||
logger = logging.getLogger(__name__)
|
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.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: UUID of the repository owner.
|
|
||||||
project_id: UUID of the project.
|
|
||||||
name: Repository name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Absolute path to the repository directory.
|
|
||||||
"""
|
|
||||||
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 _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
|
|
||||||
"""Prepare environment variables for git commands with SSH authentication.
|
|
||||||
|
|
||||||
Returns a dict of extra env vars, or None if no SSH key provided.
|
|
||||||
The caller is responsible for cleaning up the temporary key file.
|
|
||||||
"""
|
|
||||||
if ssh_key is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
# Decrypt private key
|
|
||||||
fernet = _get_fernet()
|
|
||||||
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
|
||||||
|
|
||||||
# Write to temp file with restricted permissions
|
|
||||||
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
|
||||||
try:
|
|
||||||
os.write(fd, private_key.encode())
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
os.chmod(key_path, 0o600)
|
|
||||||
|
|
||||||
# Return env vars and the key path for cleanup
|
|
||||||
env = {
|
|
||||||
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
||||||
}
|
|
||||||
return env, key_path
|
|
||||||
|
|
||||||
|
|
||||||
def _preflight_remote_repository(
|
|
||||||
remote_url: str, ssh_key: SSHKey | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Verify a remote repository is reachable before cloning."""
|
|
||||||
env = None
|
|
||||||
key_path = None
|
|
||||||
|
|
||||||
if ssh_key is not None:
|
|
||||||
ssh_result = _prepare_ssh_env(ssh_key)
|
|
||||||
if ssh_result:
|
|
||||||
env, key_path = ssh_result
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "ls-remote", remote_url],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
env={**os.environ, **env} if env else None,
|
|
||||||
)
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if key_path and os.path.exists(key_path):
|
|
||||||
os.unlink(key_path)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(
|
|
||||||
"Preflight check failed for %s: stderr=%s", remote_url, result.stderr
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"repository not found or inaccessible: {result.stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _clone_working_repository(
|
|
||||||
remote_url: str, repo_path: str, ssh_key: SSHKey | None = None
|
|
||||||
) -> None:
|
|
||||||
env = None
|
|
||||||
key_path = None
|
|
||||||
|
|
||||||
if ssh_key is not None:
|
|
||||||
ssh_result = _prepare_ssh_env(ssh_key)
|
|
||||||
if ssh_result:
|
|
||||||
env, key_path = ssh_result
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "clone", remote_url, repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300,
|
|
||||||
env={**os.environ, **env} if env else None,
|
|
||||||
)
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if key_path and os.path.exists(key_path):
|
|
||||||
os.unlink(key_path)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
|
|
||||||
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}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/repositories",
|
"/repositories",
|
||||||
response_model=list[GitRepositoryResponse],
|
response_model=list[GitRepositoryResponse],
|
||||||
@@ -350,7 +184,7 @@ async def create_external_repository(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
_preflight_remote_repository(remote_url, ssh_key)
|
preflight_remote_repository(remote_url, ssh_key)
|
||||||
|
|
||||||
# Create external repo with no project
|
# Create external repo with no project
|
||||||
repo = GitRepository(
|
repo = GitRepository(
|
||||||
@@ -370,7 +204,7 @@ async def create_external_repository(
|
|||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
try:
|
try:
|
||||||
_clone_working_repository(remote_url, repo_path, ssh_key)
|
clone_working_repository(remote_url, repo_path, ssh_key)
|
||||||
repo.is_mirror = False
|
repo.is_mirror = False
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
@@ -541,17 +375,17 @@ async def create_repository(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
_preflight_remote_repository(remote_url, ssh_key)
|
preflight_remote_repository(remote_url, ssh_key)
|
||||||
|
|
||||||
repo_path = _get_repo_path(user_id, project_id, data.name)
|
repo_path = get_repo_path(user_id, project_id, data.name)
|
||||||
|
|
||||||
# Ensure parent directory exists
|
# Ensure parent directory exists
|
||||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
_clone_working_repository(remote_url, repo_path, ssh_key)
|
clone_working_repository(remote_url, repo_path, ssh_key)
|
||||||
else:
|
else:
|
||||||
_init_working_repository(repo_path)
|
init_working_repository(repo_path)
|
||||||
|
|
||||||
repo = GitRepository(
|
repo = GitRepository(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -969,7 +803,7 @@ async def get_repository_branches(
|
|||||||
if repo.ssh_key_id:
|
if repo.ssh_key_id:
|
||||||
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||||
|
|
||||||
ssh_result = _prepare_ssh_env(ssh_key)
|
ssh_result = prepare_ssh_env(ssh_key)
|
||||||
env = None
|
env = None
|
||||||
key_path = None
|
key_path = None
|
||||||
if ssh_result:
|
if ssh_result:
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Config profile CRUD service functions."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.models import ConfigProfile, ConfigProfileInclude, ToolType, UserConfig
|
||||||
|
from src.models.project import Project
|
||||||
|
|
||||||
|
MAX_PROFILE_SIZE_MB = 10
|
||||||
|
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_profile_size(data: dict) -> int:
|
||||||
|
"""Calculate approximate serialized size of profile data."""
|
||||||
|
total = 0
|
||||||
|
for key, value in data.get("env_vars", {}).items():
|
||||||
|
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||||
|
for key, value in data.get("runtime_hints", {}).items():
|
||||||
|
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||||
|
for mount in data.get("mounts", []):
|
||||||
|
total += len(str(mount.get("target", "")).encode("utf-8"))
|
||||||
|
total += len(str(mount.get("mode", "")).encode("utf-8"))
|
||||||
|
for path, content in mount.get("files", {}).items():
|
||||||
|
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||||
|
for path, content in data.get("files", {}).items():
|
||||||
|
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_profile_with_includes(
|
||||||
|
session: AsyncSession, profile_id: uuid.UUID
|
||||||
|
) -> ConfigProfile | None:
|
||||||
|
"""Fetch a profile with includes eagerly loaded."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile_id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
tool_type_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Verify user has access to referenced project and tool type."""
|
||||||
|
if project_id is not None:
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||||
|
)
|
||||||
|
if tool_type_id is not None:
|
||||||
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_git_mounts(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
git_mounts: list[Any],
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate git mount URLs."""
|
||||||
|
for mount in git_mounts:
|
||||||
|
remote_url = mount.get("remote_url")
|
||||||
|
if not remote_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Git mount missing remote_url",
|
||||||
|
)
|
||||||
|
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid git URL: {remote_url}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_to_response(
|
||||||
|
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(profile.id),
|
||||||
|
"user_id": str(profile.user_id),
|
||||||
|
"name": profile.name,
|
||||||
|
"description": profile.description,
|
||||||
|
"project_id": str(profile.project_id) if profile.project_id else None,
|
||||||
|
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
|
||||||
|
"env_vars": profile.env_vars or {},
|
||||||
|
"runtime_hints": profile.runtime_hints or {},
|
||||||
|
"mounts": profile.mounts or [],
|
||||||
|
"git_mounts": profile.git_mounts or [],
|
||||||
|
"files": profile.files or {},
|
||||||
|
"is_default": profile.is_default,
|
||||||
|
"includes": [
|
||||||
|
{
|
||||||
|
"id": str(inc.id),
|
||||||
|
"included_profile_id": str(inc.included_profile_id),
|
||||||
|
"order_index": inc.order_index,
|
||||||
|
}
|
||||||
|
for inc in (includes or profile.includes)
|
||||||
|
],
|
||||||
|
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||||
|
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
try:
|
||||||
|
profile_uuid = uuid.UUID(profile_id_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
|
||||||
|
)
|
||||||
|
profile = await session.get(ConfigProfile, profile_uuid)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Profile not found: {profile_id_str}",
|
||||||
|
)
|
||||||
|
if profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Profile does not belong to user: {profile_id_str}",
|
||||||
|
)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Config profile resolver service functions."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models import ConfigProfile, SSHKey, UserConfig
|
||||||
|
from src.services.shared.ssh_keys import _get_fernet
|
||||||
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
from src.schemas.config import ValidateGitUrlResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_default_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
tool_type_id: uuid.UUID,
|
||||||
|
) -> dict:
|
||||||
|
"""Resolve the default config profile for a project/tool combination."""
|
||||||
|
query = (
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.user_id == user_id)
|
||||||
|
.where(
|
||||||
|
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
||||||
|
| (ConfigProfile.project_id == project_id)
|
||||||
|
| (ConfigProfile.tool_type_id == tool_type_id)
|
||||||
|
| (
|
||||||
|
(ConfigProfile.project_id == project_id)
|
||||||
|
& (ConfigProfile.tool_type_id == tool_type_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(ConfigProfile.created_at)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
|
||||||
|
if not profiles:
|
||||||
|
return {"profile_id": None, "profile_name": None}
|
||||||
|
|
||||||
|
explicit_defaults = [p for p in profiles if p.is_default]
|
||||||
|
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_id and p.tool_type_id == tool_type_id:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_id and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id == tool_type_id:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
first = profiles[0]
|
||||||
|
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_git_url(
|
||||||
|
session: AsyncSession,
|
||||||
|
current_user_id: uuid.UUID,
|
||||||
|
url: str,
|
||||||
|
ssh_key_id: str | None,
|
||||||
|
) -> ValidateGitUrlResponse:
|
||||||
|
"""Validate a git remote URL and list available branches."""
|
||||||
|
parse_result = parse_git_url(url)
|
||||||
|
original_url = url.strip()
|
||||||
|
url_to_check = parse_result.get("base_url") or original_url
|
||||||
|
|
||||||
|
if not url_to_check:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error=parse_result.get("message", "Invalid URL"),
|
||||||
|
error_code=parse_result.get("error_code", "INVALID_URL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if parse_result.get("needs_parsing") and url_to_check != original_url:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
suggested_url=url_to_check,
|
||||||
|
error=parse_result.get("message"),
|
||||||
|
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
|
||||||
|
)
|
||||||
|
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
if ssh_key_id:
|
||||||
|
try:
|
||||||
|
ssh_key_uuid = uuid.UUID(ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="Invalid SSH key ID format",
|
||||||
|
error_code="INVALID_SSH_KEY",
|
||||||
|
)
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_uuid)
|
||||||
|
if ssh_key is None or ssh_key.user_id != current_user_id:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="SSH key not found or not authorized",
|
||||||
|
error_code="SSH_KEY_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||||
|
try:
|
||||||
|
os.write(fd, private_key.encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
env = {
|
||||||
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", "--heads", url_to_check],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="Remote repository check timed out",
|
||||||
|
error_code="TIMEOUT",
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="git command not found on server",
|
||||||
|
error_code="GIT_NOT_FOUND",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
if (
|
||||||
|
"could not resolve" in stderr.lower()
|
||||||
|
or "unable to access" in stderr.lower()
|
||||||
|
):
|
||||||
|
error_msg = "Could not reach repository. Check the URL and network access."
|
||||||
|
error_code = "UNREACHABLE"
|
||||||
|
elif (
|
||||||
|
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
|
||||||
|
):
|
||||||
|
error_msg = (
|
||||||
|
"Authentication failed. Provide an SSH key for private repositories."
|
||||||
|
)
|
||||||
|
error_code = "AUTH_FAILED"
|
||||||
|
else:
|
||||||
|
error_msg = f"Repository not accessible: {stderr[:200]}"
|
||||||
|
error_code = "REMOTE_ERROR"
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error=error_msg,
|
||||||
|
error_code=error_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
branches: list[str] = []
|
||||||
|
default_branch = "main"
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) == 2:
|
||||||
|
ref = parts[1]
|
||||||
|
if ref.startswith("refs/heads/"):
|
||||||
|
branch_name = ref[len("refs/heads/") :]
|
||||||
|
branches.append(branch_name)
|
||||||
|
if branch_name in ("main", "master"):
|
||||||
|
default_branch = branch_name
|
||||||
|
|
||||||
|
if not branches:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="No branches found in remote repository",
|
||||||
|
error_code="NO_BRANCHES",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=True,
|
||||||
|
suggested_url=url_to_check if url_to_check != original_url else None,
|
||||||
|
branches=branches,
|
||||||
|
default_branch=default_branch,
|
||||||
|
)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Git repository operations service."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from src.config import Settings
|
||||||
|
from src.models import SSHKey
|
||||||
|
from src.services.shared.ssh_keys import _get_fernet
|
||||||
|
|
||||||
|
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 prepare_ssh_env(ssh_key: SSHKey | None) -> tuple[dict, str] | None:
|
||||||
|
"""Prepare environment variables for git commands with SSH authentication."""
|
||||||
|
if ssh_key is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||||
|
try:
|
||||||
|
os.write(fd, private_key.encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
}
|
||||||
|
return env, key_path
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
|
||||||
|
"""Verify a remote repository is reachable before cloning."""
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
|
||||||
|
if ssh_key is not None:
|
||||||
|
ssh_result = prepare_ssh_env(ssh_key)
|
||||||
|
if ssh_result:
|
||||||
|
env, key_path = ssh_result
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", remote_url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(
|
||||||
|
"Preflight check failed for %s: stderr=%s", remote_url, result.stderr
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"repository not found or inaccessible: {result.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
|
||||||
|
"""Clone a remote repository to a local path."""
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
|
||||||
|
if ssh_key is not None:
|
||||||
|
ssh_result = prepare_ssh_env(ssh_key)
|
||||||
|
if ssh_result:
|
||||||
|
env, key_path = ssh_result
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "clone", remote_url, repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
|
||||||
|
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:
|
||||||
|
"""Initialize a new git repository at the given path."""
|
||||||
|
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}",
|
||||||
|
)
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { GitMountEditor } from "../git/git-mount-editor";
|
||||||
|
import type { ConfigProfile, CreateConfigProfileRequest, ResolvedProfile } from "../../../api/config-profiles";
|
||||||
|
import type { ProjectWithRepos } from "../../../types";
|
||||||
|
import type { ToolType } from "../../../api/tool-types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isCreating: boolean;
|
||||||
|
selectedProfile: ConfigProfile | null;
|
||||||
|
formData: CreateConfigProfileRequest;
|
||||||
|
includedProfileIds: string[];
|
||||||
|
dragOverIndex: number | null;
|
||||||
|
error: string | null;
|
||||||
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
|
previewData: ResolvedProfile | null;
|
||||||
|
previewingId: string | null;
|
||||||
|
projects: ProjectWithRepos[];
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
availableProfiles: ConfigProfile[];
|
||||||
|
getIncludedProfile: (id: string) => ConfigProfile | undefined;
|
||||||
|
getScopeLabel: (profile: ConfigProfile) => string;
|
||||||
|
onFormChange: <K extends keyof CreateConfigProfileRequest>(key: K, value: CreateConfigProfileRequest[K]) => void;
|
||||||
|
onSubmit: (e?: React.FormEvent) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onPreview: () => void;
|
||||||
|
onAddInclude: (id: string) => void;
|
||||||
|
onRemoveInclude: (index: number) => void;
|
||||||
|
onDragStart: (e: React.DragEvent, index: number) => void;
|
||||||
|
onDragOver: (e: React.DragEvent, index: number) => void;
|
||||||
|
onDragLeave: () => void;
|
||||||
|
onDrop: (e: React.DragEvent, index: number) => void;
|
||||||
|
onAddEnvVar: () => void;
|
||||||
|
onUpdateEnvVar: (oldKey: string, newKey: string, value: string) => void;
|
||||||
|
onRemoveEnvVar: (key: string) => void;
|
||||||
|
onAddFile: () => void;
|
||||||
|
onUpdateFile: (oldPath: string, newPath: string, content: string) => void;
|
||||||
|
onRemoveFile: (path: string) => void;
|
||||||
|
onAddMount: () => void;
|
||||||
|
onUpdateMount: (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => void;
|
||||||
|
onRemoveMount: (index: number) => void;
|
||||||
|
onAddMountFile: (mountIndex: number) => void;
|
||||||
|
onUpdateMountFile: (mountIndex: number, oldPath: string, newPath: string, content: string) => void;
|
||||||
|
onRemoveMountFile: (mountIndex: number, path: string) => void;
|
||||||
|
onClosePreview: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigProfileEditorPanel = ({
|
||||||
|
isCreating,
|
||||||
|
selectedProfile,
|
||||||
|
formData,
|
||||||
|
includedProfileIds,
|
||||||
|
dragOverIndex,
|
||||||
|
error,
|
||||||
|
saveStatus,
|
||||||
|
previewData,
|
||||||
|
previewingId,
|
||||||
|
projects,
|
||||||
|
toolTypes,
|
||||||
|
availableProfiles,
|
||||||
|
getIncludedProfile,
|
||||||
|
getScopeLabel,
|
||||||
|
onFormChange,
|
||||||
|
onSubmit,
|
||||||
|
onReset,
|
||||||
|
onPreview,
|
||||||
|
onAddInclude,
|
||||||
|
onRemoveInclude,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onAddEnvVar,
|
||||||
|
onUpdateEnvVar,
|
||||||
|
onRemoveEnvVar,
|
||||||
|
onAddFile,
|
||||||
|
onUpdateFile,
|
||||||
|
onRemoveFile,
|
||||||
|
onAddMount,
|
||||||
|
onUpdateMount,
|
||||||
|
onRemoveMount,
|
||||||
|
onAddMountFile,
|
||||||
|
onUpdateMountFile,
|
||||||
|
onRemoveMountFile,
|
||||||
|
onClosePreview,
|
||||||
|
}: Props) => {
|
||||||
|
const hasSelection = isCreating || selectedProfile;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
|
||||||
|
{!hasSelection ? (
|
||||||
|
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
|
||||||
|
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
|
||||||
|
<Icon name="folder" size="lg" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a config profile</h3>
|
||||||
|
<p style={{ margin: 0 }}>Choose a profile from the list to edit, or create a new one.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
|
||||||
|
{isCreating ? "Create Profile" : selectedProfile?.name}
|
||||||
|
</h1>
|
||||||
|
{!isCreating && selectedProfile && (
|
||||||
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
|
{selectedProfile.project_id &&
|
||||||
|
`Project: ${projects.find((p) => p.id === selectedProfile.project_id)?.name || selectedProfile.project_id}`}
|
||||||
|
{selectedProfile.project_id && selectedProfile.tool_type_id && " · "}
|
||||||
|
{selectedProfile.tool_type_id &&
|
||||||
|
`Tool: ${toolTypes.find((t) => t.id === selectedProfile.tool_type_id)?.display_name || selectedProfile.tool_type_id}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isCreating && selectedProfile && (
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button className="secondary-button" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
|
||||||
|
{previewingId === selectedProfile.id ? (
|
||||||
|
<><Icon name="loading" size="sm" /> Previewing...</>
|
||||||
|
) : (
|
||||||
|
<><Icon name="info" size="sm" /> Preview</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error" style={{ marginBottom: "1rem" }}>{error}</div>}
|
||||||
|
|
||||||
|
{saveStatus === "saved" && (
|
||||||
|
<div style={{ marginBottom: "1rem", padding: "0.75rem 1rem", background: "var(--success-bg, #dcfce7)", color: "var(--success, #166534)", borderRadius: "0.375rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<Icon name="success" size="sm" />
|
||||||
|
Profile saved successfully
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="stack" style={{ gap: "1.25rem", maxWidth: "800px" }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="profile-name">Name *</label>
|
||||||
|
<input id="profile-name" type="text" value={formData.name} onChange={(e) => onFormChange("name", e.target.value)} placeholder="e.g., Development Environment" className="form-input" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="profile-description">Description</label>
|
||||||
|
<input id="profile-description" type="text" value={formData.description || ""} onChange={(e) => onFormChange("description", e.target.value || undefined)} placeholder="Optional description" className="form-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="profile-project">Project</label>
|
||||||
|
<select id="profile-project" value={formData.project_id || ""} onChange={(e) => onFormChange("project_id", e.target.value || undefined)} className="form-input">
|
||||||
|
<option value="">None (Global)</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>{project.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="profile-tool">Tool Type</label>
|
||||||
|
<select id="profile-tool" value={formData.tool_type_id || ""} onChange={(e) => onFormChange("tool_type_id", e.target.value || undefined)} className="form-input">
|
||||||
|
<option value="">None</option>
|
||||||
|
{toolTypes.map((toolType) => (
|
||||||
|
<option key={toolType.id} value={toolType.id}>{toolType.display_name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input type="checkbox" checked={formData.is_default || false} onChange={(e) => onFormChange("is_default", e.target.checked)} />
|
||||||
|
Set as default for this scope
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
||||||
|
<h4 style={{ margin: 0 }}>Includes</h4>
|
||||||
|
<span className="muted" style={{ fontSize: "0.875rem" }}>{includedProfileIds.length} included</span>
|
||||||
|
</div>
|
||||||
|
{includedProfileIds.length === 0 ? (
|
||||||
|
<p className="muted" style={{ fontSize: "0.875rem", margin: "0 0 0.75rem 0" }}>No profiles included. Add profiles to compose configurations.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
{includedProfileIds.map((profileId, index) => {
|
||||||
|
const profile = getIncludedProfile(profileId);
|
||||||
|
if (!profile) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${profileId}-${index}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, index)}
|
||||||
|
onDragOver={(e) => onDragOver(e, index)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, index)}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 0.75rem", background: dragOverIndex === index ? "var(--brand-bg, #e0e7ff)" : "var(--panel)", border: "1px solid var(--border)", borderRadius: "0.375rem", marginBottom: "0.25rem", cursor: "grab", transition: "background 0.15s" }}
|
||||||
|
>
|
||||||
|
<span style={{ cursor: "grab", color: "var(--muted)" }}><Icon name="drag" size="sm" /></span>
|
||||||
|
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
|
||||||
|
<span style={{ fontSize: "0.75rem", padding: "0.125rem 0.375rem", background: "var(--badge-bg, #f3f4f6)", color: "var(--muted)", borderRadius: "0.25rem", textTransform: "uppercase", letterSpacing: "0.025em" }}>{getScopeLabel(profile)}</span>
|
||||||
|
<button type="button" onClick={() => onRemoveInclude(index)} style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", padding: "0.25rem", borderRadius: "0.25rem" }} title="Remove include"><Icon name="delete" size="sm" /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{availableProfiles.length > 0 && (
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<select value="" onChange={(e) => { if (e.target.value) { onAddInclude(e.target.value); e.target.value = ""; } }} className="form-input">
|
||||||
|
<option value="">+ Add Include...</option>
|
||||||
|
{availableProfiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name} ({getScopeLabel(p)})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
|
||||||
|
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
|
||||||
|
<div key={idx} className="form-row" style={{ gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
<input type="text" value={key} onChange={(e) => onUpdateEnvVar(key, e.target.value, value)} placeholder="VAR_NAME" className="form-input" style={{ flex: 1 }} />
|
||||||
|
<input type="text" value={value} onChange={(e) => onUpdateEnvVar(key, key, e.target.value)} placeholder="value" className="form-input" style={{ flex: 1 }} />
|
||||||
|
<button type="button" className="ghost-button small" onClick={() => onRemoveEnvVar(key)}><Icon name="delete" size="sm" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="secondary-button" onClick={onAddEnvVar}><Icon name="add" size="sm" /> Add Variable</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h4 style={{ margin: "0 0 0.75rem 0" }}>Runtime Hints</h4>
|
||||||
|
<textarea value={JSON.stringify(formData.runtime_hints || {}, null, 2)} onChange={(e) => { try { const parsed = JSON.parse(e.target.value); onFormChange("runtime_hints", parsed); } catch { /* ignore */ } }} placeholder='{"start_command": "npm start"}' rows={4} className="form-input" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h4 style={{ margin: "0 0 0.5rem 0" }}>Files</h4>
|
||||||
|
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>Relative paths written to the instance directory. Use Mounts below for absolute container paths.</p>
|
||||||
|
{Object.entries(formData.files || {}).map(([path, content], idx) => (
|
||||||
|
<div key={idx} className="card" style={{ padding: "0.75rem", marginBottom: "0.5rem" }}>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
<input type="text" value={path} onChange={(e) => onUpdateFile(path, e.target.value, content)} placeholder="relative/path/to/file" className="form-input" style={{ flex: 1 }} />
|
||||||
|
<button type="button" className="ghost-button small" onClick={() => onRemoveFile(path)}><Icon name="delete" size="sm" /></button>
|
||||||
|
</div>
|
||||||
|
<textarea value={content} onChange={(e) => onUpdateFile(path, path, e.target.value)} placeholder="File content" rows={3} className="form-input" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="secondary-button" onClick={onAddFile}><Icon name="add" size="sm" /> Add File</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h4 style={{ margin: "0 0 0.5rem 0" }}>Mounts</h4>
|
||||||
|
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>Bind directories into the container at absolute paths. Files are relative to the mount target.</p>
|
||||||
|
{(formData.mounts || []).map((mount, index) => (
|
||||||
|
<div key={index} className="card" style={{ padding: "1rem", marginBottom: "0.75rem" }}>
|
||||||
|
<div className="form-row" style={{ gap: "0.5rem", marginBottom: "0.75rem" }}>
|
||||||
|
<input type="text" value={mount.target} onChange={(e) => onUpdateMount(index, { target: e.target.value })} placeholder="/target/path" className="form-input" style={{ flex: 1 }} />
|
||||||
|
<select value={mount.mode} onChange={(e) => onUpdateMount(index, { mode: e.target.value as "ro" | "rw" })} className="form-input" style={{ width: "120px" }}>
|
||||||
|
<option value="rw">Read/Write</option>
|
||||||
|
<option value="ro">Read-Only</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" className="ghost-button small" onClick={() => onRemoveMount(index)}><Icon name="delete" size="sm" /></button>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: "1rem" }}>
|
||||||
|
{Object.entries(mount.files).map(([path, content], idx) => (
|
||||||
|
<div key={idx} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
<input type="text" value={path} onChange={(e) => onUpdateMountFile(index, path, e.target.value, content)} placeholder="relative/path" className="form-input" style={{ flex: 1 }} />
|
||||||
|
<textarea value={content} onChange={(e) => onUpdateMountFile(index, path, path, e.target.value)} placeholder="File content" rows={2} className="form-input" style={{ flex: 2, fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||||
|
<button type="button" className="ghost-button small" onClick={() => onRemoveMountFile(index, path)}><Icon name="delete" size="sm" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="secondary-button small" onClick={() => onAddMountFile(index)} style={{ fontSize: "0.875rem" }}><Icon name="add" size="sm" /> Add File to Mount</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="secondary-button" onClick={onAddMount}><Icon name="add" size="sm" /> Add Mount</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<GitMountEditor mounts={formData.git_mounts || []} onChange={(git_mounts) => onFormChange("git_mounts", git_mounts)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
|
||||||
|
<button type="submit" disabled={saveStatus === "saving"}>
|
||||||
|
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
||||||
|
{saveStatus === "saving" ? "Saving..." : isCreating ? "Create Profile" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
{(isCreating || saveStatus !== "idle") && (
|
||||||
|
<button type="button" onClick={onReset} className="button-secondary"><Icon name="cancel" size="sm" /> Discard</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{previewData && (
|
||||||
|
<div className="card stack" style={{ marginTop: "2rem", padding: "1rem" }}>
|
||||||
|
<h3>Resolved Profile Preview</h3>
|
||||||
|
<pre style={{ overflow: "auto", maxHeight: "400px", fontSize: "0.8125rem" }}>{JSON.stringify(previewData, null, 2)}</pre>
|
||||||
|
<button className="secondary-button" onClick={onClosePreview}>Close Preview</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
import type { ConfigProfile } from "../../../api/config-profiles";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profiles: ConfigProfile[];
|
||||||
|
selectedProfileId: string | null;
|
||||||
|
onSelect: (profile: ConfigProfile) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigProfileListSidebar = ({
|
||||||
|
profiles,
|
||||||
|
selectedProfileId,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "280px",
|
||||||
|
minWidth: "280px",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "var(--panel)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Config Profiles</h2>
|
||||||
|
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
|
||||||
|
{profiles.length} profile{profiles.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<button
|
||||||
|
key={profile.id}
|
||||||
|
onClick={() => onSelect(profile)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: "none",
|
||||||
|
background: selectedProfileId === profile.id ? "var(--brand)" : "transparent",
|
||||||
|
color: selectedProfileId === profile.id ? "white" : "var(--ink)",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
transition: "background 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (selectedProfileId !== profile.id) {
|
||||||
|
e.currentTarget.style.background = "#ece7df";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (selectedProfileId !== profile.id) {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.9375rem",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile.name}
|
||||||
|
{profile.is_default && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
opacity: 0.8,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.025em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{profile.includes?.length > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
opacity: 0.7,
|
||||||
|
background:
|
||||||
|
selectedProfileId === profile.id
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "var(--badge-bg, #f3f4f6)",
|
||||||
|
padding: "0.0625rem 0.375rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}
|
||||||
|
>
|
||||||
|
{profile.project_id && "Project scoped"}
|
||||||
|
{profile.tool_type_id && (profile.project_id ? " + Tool scoped" : "Tool scoped")}
|
||||||
|
{!profile.project_id && !profile.tool_type_id && "Global"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(profile.id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: selectedProfileId === profile.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0.25rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
className="delete-btn"
|
||||||
|
title="Delete profile"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
|
||||||
|
<button
|
||||||
|
onClick={onCreate}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.75rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "2px dashed var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--brand)";
|
||||||
|
e.currentTarget.style.color = "var(--brand)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
|
e.currentTarget.style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> New Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { MobileListView } from "../mobile/mobile-list-view";
|
||||||
|
import { MobileDetailView } from "../mobile/mobile-detail-view";
|
||||||
|
import { MobileEditView } from "../mobile/mobile-edit-view";
|
||||||
|
import { MobileFAB } from "../mobile/mobile-fab";
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import type { ConfigProfile, CreateConfigProfileRequest } from "../../../api/config-profiles";
|
||||||
|
|
||||||
|
type MobileView = "list" | "detail" | "edit";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profiles: ConfigProfile[];
|
||||||
|
selectedProfile: ConfigProfile | null;
|
||||||
|
mobileView: MobileView;
|
||||||
|
isCreating: boolean;
|
||||||
|
formData: CreateConfigProfileRequest;
|
||||||
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
|
onViewChange: (view: MobileView) => void;
|
||||||
|
onSelect: (profile: ConfigProfile) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onFormChange: <K extends keyof CreateConfigProfileRequest>(key: K, value: CreateConfigProfileRequest[K]) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
getScopeLabel: (profile: ConfigProfile) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigProfilesMobileView = ({
|
||||||
|
profiles,
|
||||||
|
selectedProfile,
|
||||||
|
mobileView,
|
||||||
|
isCreating,
|
||||||
|
formData,
|
||||||
|
saveStatus,
|
||||||
|
onViewChange,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
onFormChange,
|
||||||
|
onSubmit,
|
||||||
|
getScopeLabel,
|
||||||
|
}: Props) => {
|
||||||
|
if (mobileView === "list") {
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<div className="mobile-page-header">
|
||||||
|
<h1>Config Profiles</h1>
|
||||||
|
</div>
|
||||||
|
<MobileListView
|
||||||
|
items={profiles.map((profile) => ({
|
||||||
|
id: profile.id,
|
||||||
|
title: profile.name,
|
||||||
|
subtitle: profile.description || getScopeLabel(profile),
|
||||||
|
}))}
|
||||||
|
onItemClick={(id: string) => {
|
||||||
|
const profile = profiles.find((p) => p.id === id);
|
||||||
|
if (profile) {
|
||||||
|
onSelect(profile);
|
||||||
|
onViewChange("detail");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onItemDelete={(id: string) => onDelete(id)}
|
||||||
|
emptyMessage="No config profiles yet"
|
||||||
|
/>
|
||||||
|
<MobileFAB
|
||||||
|
onClick={() => {
|
||||||
|
onCreate();
|
||||||
|
onViewChange("edit");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileView === "detail" && selectedProfile) {
|
||||||
|
const fields = [
|
||||||
|
{ label: "Name", value: selectedProfile.name },
|
||||||
|
{ label: "Description", value: selectedProfile.description || "-" },
|
||||||
|
{ label: "Scope", value: getScopeLabel(selectedProfile) },
|
||||||
|
{ label: "Default", value: selectedProfile.is_default ? "Yes" : "No" },
|
||||||
|
{
|
||||||
|
label: "Environment Variables",
|
||||||
|
value:
|
||||||
|
Object.keys(selectedProfile.env_vars).length > 0
|
||||||
|
? Object.entries(selectedProfile.env_vars)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(", ")
|
||||||
|
: "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mounts",
|
||||||
|
value:
|
||||||
|
selectedProfile.mounts.length > 0
|
||||||
|
? selectedProfile.mounts.map((m) => `${m.target} (${m.mode})`).join(", ")
|
||||||
|
: "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Includes",
|
||||||
|
value:
|
||||||
|
selectedProfile.includes.length > 0
|
||||||
|
? `${selectedProfile.includes.length} profile(s)`
|
||||||
|
: "-",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileDetailView
|
||||||
|
title={selectedProfile.name}
|
||||||
|
subtitle={getScopeLabel(selectedProfile)}
|
||||||
|
fields={fields}
|
||||||
|
onBack={() => onViewChange("list")}
|
||||||
|
onEdit={() => {
|
||||||
|
onSelect(selectedProfile);
|
||||||
|
onViewChange("edit");
|
||||||
|
}}
|
||||||
|
onDelete={() => onDelete(selectedProfile.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileView === "edit") {
|
||||||
|
return (
|
||||||
|
<MobileEditView
|
||||||
|
title={isCreating ? "Create Profile" : "Edit Profile"}
|
||||||
|
onCancel={() => {
|
||||||
|
onViewChange(isCreating ? "list" : "detail");
|
||||||
|
}}
|
||||||
|
onSave={onSubmit}
|
||||||
|
isSaving={saveStatus === "saving"}
|
||||||
|
>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => onFormChange("name", e.target.value)}
|
||||||
|
placeholder="Profile name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description || ""}
|
||||||
|
onChange={(e) => onFormChange("description", e.target.value || undefined)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Project</label>
|
||||||
|
<select
|
||||||
|
value={formData.project_id || ""}
|
||||||
|
onChange={(e) => onFormChange("project_id", e.target.value || undefined)}
|
||||||
|
>
|
||||||
|
<option value="">Global (all projects)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Tool Type</label>
|
||||||
|
<select
|
||||||
|
value={formData.tool_type_id || ""}
|
||||||
|
onChange={(e) => onFormChange("tool_type_id", e.target.value || undefined)}
|
||||||
|
>
|
||||||
|
<option value="">Any tool type</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_default || false}
|
||||||
|
onChange={(e) => onFormChange("is_default", e.target.checked)}
|
||||||
|
/>
|
||||||
|
Default Profile
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Environment Variables</label>
|
||||||
|
{Object.entries(formData.env_vars || {}).map(([key, value], index) => (
|
||||||
|
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newEnvVars = { ...formData.env_vars };
|
||||||
|
delete newEnvVars[key];
|
||||||
|
newEnvVars[e.target.value] = value;
|
||||||
|
onFormChange("env_vars", newEnvVars);
|
||||||
|
}}
|
||||||
|
placeholder="KEY"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newEnvVars = { ...formData.env_vars };
|
||||||
|
newEnvVars[key] = e.target.value;
|
||||||
|
onFormChange("env_vars", newEnvVars);
|
||||||
|
}}
|
||||||
|
placeholder="value"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newEnvVars = { ...formData.env_vars };
|
||||||
|
delete newEnvVars[key];
|
||||||
|
onFormChange("env_vars", newEnvVars);
|
||||||
|
}}
|
||||||
|
className="secondary-button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
onFormChange("env_vars", { ...formData.env_vars, "": "" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> Add Variable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Mounts</label>
|
||||||
|
{(formData.mounts || []).map((mount, index) => (
|
||||||
|
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mount.target}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newMounts = [...(formData.mounts || [])];
|
||||||
|
newMounts[index] = { ...mount, target: e.target.value };
|
||||||
|
onFormChange("mounts", newMounts);
|
||||||
|
}}
|
||||||
|
placeholder="Target path"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={mount.mode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newMounts = [...(formData.mounts || [])];
|
||||||
|
newMounts[index] = { ...mount, mode: e.target.value as "ro" | "rw" };
|
||||||
|
onFormChange("mounts", newMounts);
|
||||||
|
}}
|
||||||
|
style={{ width: "80px" }}
|
||||||
|
>
|
||||||
|
<option value="ro">Read</option>
|
||||||
|
<option value="rw">Write</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newMounts = (formData.mounts || []).filter((_, i) => i !== index);
|
||||||
|
onFormChange("mounts", newMounts);
|
||||||
|
}}
|
||||||
|
className="secondary-button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
onFormChange("mounts", [...(formData.mounts || []), { target: "/", mode: "rw", files: {} }]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> Add Mount
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MobileEditView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<div className="mobile-page-header">
|
||||||
|
<h1>Config Profiles</h1>
|
||||||
|
</div>
|
||||||
|
<MobileListView
|
||||||
|
items={profiles.map((profile) => ({
|
||||||
|
id: profile.id,
|
||||||
|
title: profile.name,
|
||||||
|
subtitle: profile.description || getScopeLabel(profile),
|
||||||
|
}))}
|
||||||
|
onItemClick={(id: string) => {
|
||||||
|
const profile = profiles.find((p) => p.id === id);
|
||||||
|
if (profile) {
|
||||||
|
onSelect(profile);
|
||||||
|
onViewChange("detail");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onItemDelete={(id: string) => onDelete(id)}
|
||||||
|
emptyMessage="No config profiles yet"
|
||||||
|
/>
|
||||||
|
<MobileFAB
|
||||||
|
onClick={() => {
|
||||||
|
onCreate();
|
||||||
|
onViewChange("edit");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { WorkspaceCreateForm } from "../workspace/workspace-create-form";
|
||||||
|
import type { ProjectWithRepos, WorkspaceSummary } from "../../../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: ProjectWithRepos;
|
||||||
|
expanded: boolean;
|
||||||
|
deleteConfirm: boolean;
|
||||||
|
workspaceLoading: string | null;
|
||||||
|
showCreateForm: string | null;
|
||||||
|
onToggle: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onConfirmDelete: () => void;
|
||||||
|
onCancelDelete: () => void;
|
||||||
|
onCreateWorkspace: (repoId: string) => void;
|
||||||
|
onWorkspaceAction: (repoId: string, workspace: WorkspaceSummary, action: "sync" | "delete") => void;
|
||||||
|
onCancelCreate: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectCard = ({
|
||||||
|
project,
|
||||||
|
expanded,
|
||||||
|
deleteConfirm,
|
||||||
|
workspaceLoading,
|
||||||
|
showCreateForm,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onConfirmDelete,
|
||||||
|
onCancelDelete,
|
||||||
|
onCreateWorkspace,
|
||||||
|
onWorkspaceAction,
|
||||||
|
onCancelCreate,
|
||||||
|
onCreated,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<article className="card project-card">
|
||||||
|
<div className="project-info-row">
|
||||||
|
<button className="project-toggle" onClick={onToggle} type="button" aria-expanded={expanded}>
|
||||||
|
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
|
||||||
|
<h3>{project.name}</h3>
|
||||||
|
{project.repositories?.length > 0 && (
|
||||||
|
<span className="repo-count">
|
||||||
|
{project.repositories.length} repo{project.repositories.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="project-actions">
|
||||||
|
<button className="ghost-button" onClick={onEdit} type="button">
|
||||||
|
<Icon name="edit" size="sm" /> Edit
|
||||||
|
</button>
|
||||||
|
{deleteConfirm ? (
|
||||||
|
<div className="delete-confirm">
|
||||||
|
<span>Are you sure?</span>
|
||||||
|
<button className="danger-button" onClick={onConfirmDelete} type="button">
|
||||||
|
<Icon name="delete" size="sm" /> Delete
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" onClick={onCancelDelete} type="button">
|
||||||
|
<Icon name="cancel" size="sm" /> Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="ghost-button danger-text" onClick={onDelete} type="button">
|
||||||
|
<Icon name="delete" size="sm" /> Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="project-detail">
|
||||||
|
{(project.repositories || []).length === 0 ? (
|
||||||
|
<p className="muted">No repositories yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="repo-list">
|
||||||
|
{(project.repositories || []).map((repo) => (
|
||||||
|
<div key={repo.id} className="repo-block">
|
||||||
|
<div className="repo-header">
|
||||||
|
<h4>{repo.name}</h4>
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={() => onCreateWorkspace(repo.id)} type="button">
|
||||||
|
<Icon name="add" size="sm" /> New Workspace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showCreateForm === repo.id && (
|
||||||
|
<WorkspaceCreateForm defaultProjectId={project.id} defaultRepoId={repo.id} onSubmit={onCreated} onCancel={onCancelCreate} />
|
||||||
|
)}
|
||||||
|
{repo.workspaces.length === 0 ? (
|
||||||
|
<p className="muted">No workspaces.</p>
|
||||||
|
) : (
|
||||||
|
<div className="workspace-grid">
|
||||||
|
{repo.workspaces.map((ws) => (
|
||||||
|
<div key={ws.id} className={`workspace-chip ${ws.status}`}>
|
||||||
|
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
|
||||||
|
<span className="ws-branch"><Icon name="branch" size="sm" /> {ws.branch}</span>
|
||||||
|
{ws.instance_count > 0 && (
|
||||||
|
<span className="ws-instances">{ws.instance_count} tool{ws.instance_count > 1 ? "s" : ""}</span>
|
||||||
|
)}
|
||||||
|
<div className="ws-actions">
|
||||||
|
<button type="button" disabled={workspaceLoading === ws.id} onClick={() => onWorkspaceAction(repo.id, ws, "sync")}>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="danger-text" disabled={workspaceLoading === ws.id} onClick={() => onWorkspaceAction(repo.id, ws, "delete")}>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
error: string | null;
|
||||||
|
onNameChange: (name: string) => void;
|
||||||
|
onDescriptionChange: (desc: string) => void;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectDialog = ({
|
||||||
|
mode,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
onNameChange,
|
||||||
|
onDescriptionChange,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="dialog">
|
||||||
|
<h2>{mode === "create" ? "Create Project" : "Edit Project"}</h2>
|
||||||
|
<form onSubmit={onSubmit} className="stack">
|
||||||
|
<label className="form-field">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
placeholder="Project name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Description
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="secondary-button" onClick={onCancel} type="button">
|
||||||
|
<Icon name="cancel" size="sm" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
{mode === "create" ? (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Create
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="save" size="sm" />
|
||||||
|
Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useOutletContext } from "react-router-dom";
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import type { UserConfig, UserConfigUpdate } from "../../../api/settings";
|
||||||
|
|
||||||
|
type SettingsOutletContext = {
|
||||||
|
config: UserConfig;
|
||||||
|
handleChange: (
|
||||||
|
key: keyof UserConfigUpdate,
|
||||||
|
value: string | string[] | null,
|
||||||
|
) => void;
|
||||||
|
handleSave: () => Promise<void>;
|
||||||
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
|
const THEME_OPTIONS = [
|
||||||
|
{ value: "system", label: "System" },
|
||||||
|
{ value: "light", label: "Light" },
|
||||||
|
{ value: "dark", label: "Dark" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOAST_LEVEL_OPTIONS = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "errors", label: "Errors only" },
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MUTE_CATEGORIES = ["instance", "system", "health", "security"];
|
||||||
|
|
||||||
|
export const GeneralSettingsTab = () => {
|
||||||
|
const { config, handleChange, handleSave, saveStatus } =
|
||||||
|
useOutletContext<SettingsOutletContext>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<h2>General</h2>
|
||||||
|
<label className="form-field">
|
||||||
|
Theme
|
||||||
|
<select
|
||||||
|
value={config.theme}
|
||||||
|
onChange={(e) => handleChange("theme", e.target.value)}
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Git user name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.git_user_name ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("git_user_name", e.target.value || null)
|
||||||
|
}
|
||||||
|
placeholder="Your git commit name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Git user email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={config.git_user_email ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("git_user_email", e.target.value || null)
|
||||||
|
}
|
||||||
|
placeholder="your.email@example.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Default editor
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.default_editor ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("default_editor", e.target.value || null)
|
||||||
|
}
|
||||||
|
placeholder="e.g., vscode, vim, cursor"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<h3>Notifications</h3>
|
||||||
|
<label className="form-field">
|
||||||
|
Toast level
|
||||||
|
<select
|
||||||
|
value={config.notification_toast_level ?? "all"}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("notification_toast_level", e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{TOAST_LEVEL_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset className="form-field">
|
||||||
|
<legend>Mute categories</legend>
|
||||||
|
<div className="stack-sm">
|
||||||
|
{MUTE_CATEGORIES.map((cat) => (
|
||||||
|
<label
|
||||||
|
key={cat}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(config.notification_mute_categories ?? []).includes(
|
||||||
|
cat,
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const current = config.notification_mute_categories ?? [];
|
||||||
|
const next = e.target.checked
|
||||||
|
? [...current, cat]
|
||||||
|
: current.filter((c) => c !== cat);
|
||||||
|
handleChange("notification_mute_categories", next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cat}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{saveStatus === "saving" ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" /> Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="save" size="sm" /> Save Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{saveStatus === "saved" && (
|
||||||
|
<span className="success-text">Settings saved!</span>
|
||||||
|
)}
|
||||||
|
{saveStatus === "error" && (
|
||||||
|
<span className="error-text">Failed to save</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
newKeyName: string;
|
||||||
|
setNewKeyName: (name: string) => void;
|
||||||
|
generating: boolean;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SSHKeyCreateForm = ({ newKeyName, setNewKeyName, generating, onSubmit }: Props) => {
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="stack">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="key-name">Key Name</label>
|
||||||
|
<input
|
||||||
|
id="key-name"
|
||||||
|
type="text"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
placeholder="e.g., GitHub Work"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="primary-button" disabled={generating}>
|
||||||
|
{generating ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Generate SSH Key
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { EmptyState, ErrorState } from "../../data-states";
|
||||||
|
import type { SSHKey } from "../../../api/ssh-keys";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
keys: SSHKey[];
|
||||||
|
status: "idle" | "loading" | "ready" | "error";
|
||||||
|
signPayloads: Record<string, string>;
|
||||||
|
signatures: Record<string, string>;
|
||||||
|
signing: Record<string, boolean>;
|
||||||
|
verifyPayloads: Record<string, string>;
|
||||||
|
verifySignatures: Record<string, string>;
|
||||||
|
verifyResults: Record<string, boolean | null>;
|
||||||
|
verifying: Record<string, boolean>;
|
||||||
|
onLoadKeys: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onCopy: (text: string) => void;
|
||||||
|
onSign: (id: string) => void;
|
||||||
|
onVerify: (id: string) => void;
|
||||||
|
onSignPayloadChange: (id: string, value: string) => void;
|
||||||
|
onVerifyPayloadChange: (id: string, value: string) => void;
|
||||||
|
onVerifySignatureChange: (id: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SSHKeyList = ({
|
||||||
|
keys,
|
||||||
|
status,
|
||||||
|
signPayloads,
|
||||||
|
signatures,
|
||||||
|
signing,
|
||||||
|
verifyPayloads,
|
||||||
|
verifySignatures,
|
||||||
|
verifyResults,
|
||||||
|
verifying,
|
||||||
|
onLoadKeys,
|
||||||
|
onDelete,
|
||||||
|
onCopy,
|
||||||
|
onSign,
|
||||||
|
onVerify,
|
||||||
|
onSignPayloadChange,
|
||||||
|
onVerifyPayloadChange,
|
||||||
|
onVerifySignatureChange,
|
||||||
|
}: Props) => {
|
||||||
|
if (status === "error") {
|
||||||
|
return <ErrorState message="Failed to load SSH keys" onRetry={onLoadKeys} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return <EmptyState message="No SSH keys yet. Generate one above." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="keys-list">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<div key={key.id} className="key-card">
|
||||||
|
<div className="key-header">
|
||||||
|
<h3>{key.name}</h3>
|
||||||
|
<button onClick={() => onDelete(key.id)} className="danger-button">
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="key-meta">
|
||||||
|
<span className="muted">
|
||||||
|
Created: {new Date(key.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="key-public">
|
||||||
|
<code>{key.public_key.substring(0, 50)}...</code>
|
||||||
|
<button
|
||||||
|
onClick={() => onCopy(key.public_key)}
|
||||||
|
className="secondary-button"
|
||||||
|
>
|
||||||
|
<Icon name="copy" size="sm" />
|
||||||
|
Copy Full Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="key-signing">
|
||||||
|
<h4>Sign Payload</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={signPayloads[key.id] || ""}
|
||||||
|
onChange={(e) => onSignPayloadChange(key.id, e.target.value)}
|
||||||
|
placeholder="Enter payload to sign..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onSign(key.id)}
|
||||||
|
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
|
||||||
|
className="primary-button"
|
||||||
|
>
|
||||||
|
{signing[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Signing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Sign
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{signatures[key.id] && (
|
||||||
|
<div className="signature-result">
|
||||||
|
<label>Signature (base64):</label>
|
||||||
|
<code>{signatures[key.id]}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => onCopy(signatures[key.id])}
|
||||||
|
className="secondary-button"
|
||||||
|
>
|
||||||
|
<Icon name="copy" size="sm" />
|
||||||
|
Copy Signature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="key-verification">
|
||||||
|
<h4>Verify Signature</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={verifyPayloads[key.id] || ""}
|
||||||
|
onChange={(e) => onVerifyPayloadChange(key.id, e.target.value)}
|
||||||
|
placeholder="Enter payload..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={verifySignatures[key.id] || ""}
|
||||||
|
onChange={(e) => onVerifySignatureChange(key.id, e.target.value)}
|
||||||
|
placeholder="Enter base64 signature..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onVerify(key.id)}
|
||||||
|
disabled={
|
||||||
|
verifying[key.id] ||
|
||||||
|
!verifyPayloads[key.id]?.trim() ||
|
||||||
|
!verifySignatures[key.id]?.trim()
|
||||||
|
}
|
||||||
|
className="primary-button"
|
||||||
|
>
|
||||||
|
{verifying[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="success" size="sm" />
|
||||||
|
Verify
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
|
||||||
|
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
|
||||||
|
{verifyResults[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="success" size="sm" />
|
||||||
|
Signature is valid
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="error" size="sm" />
|
||||||
|
Signature is invalid
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TerminalComponent, type TerminalRef } from "./terminal";
|
||||||
|
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
|
||||||
|
import type { TerminalSession } from "../../../api/terminal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instanceId: string;
|
||||||
|
sessions: TerminalSession[];
|
||||||
|
sessionInfos: TerminalSessionInfo[];
|
||||||
|
activeSessionId: string;
|
||||||
|
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
status: string;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
showResetConfirm: boolean;
|
||||||
|
onFullscreenClick: (e: React.MouseEvent<HTMLElement>) => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onRename: (id: string, name: string) => void;
|
||||||
|
onNavigateBack: () => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onFontSizeChange: (delta: number) => void;
|
||||||
|
onShowResetConfirm: () => void;
|
||||||
|
onHideResetConfirm: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onTerminalReady: (
|
||||||
|
sendData: (data: string) => void,
|
||||||
|
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
|
||||||
|
focusInput: () => void,
|
||||||
|
changeFontSize: (delta: number) => void,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DesktopTerminalView: React.FC<Props> = ({
|
||||||
|
instanceId,
|
||||||
|
sessions,
|
||||||
|
sessionInfos,
|
||||||
|
activeSessionId,
|
||||||
|
terminalRefs,
|
||||||
|
isFullscreen,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
showResetConfirm,
|
||||||
|
onFullscreenClick,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
onRename,
|
||||||
|
onNavigateBack,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onFontSizeChange,
|
||||||
|
onShowResetConfirm,
|
||||||
|
onHideResetConfirm,
|
||||||
|
onReset,
|
||||||
|
onTerminalReady,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
|
||||||
|
onClick={onFullscreenClick}
|
||||||
|
>
|
||||||
|
{!isFullscreen && (
|
||||||
|
<div className="terminal-page-header">
|
||||||
|
<button className="secondary-button" onClick={onNavigateBack} type="button">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<h1>Terminal</h1>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
type="button"
|
||||||
|
title="Toggle fullscreen (Alt+Shift+F)"
|
||||||
|
>
|
||||||
|
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isFullscreen ? (
|
||||||
|
<div className="terminal-fullscreen-header">
|
||||||
|
<div className="terminal-fullscreen-header-tabs">
|
||||||
|
<TerminalSessionTabs
|
||||||
|
sessions={sessionInfos}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onRename={onRename}
|
||||||
|
isMobile={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="terminal-fullscreen-header-controls">
|
||||||
|
<span
|
||||||
|
className={`terminal-fullscreen-status status-dot ${status}`}
|
||||||
|
aria-label={`Terminal status: ${status}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={() => onFontSizeChange(-1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Decrease font size"
|
||||||
|
>
|
||||||
|
A-
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={() => onFontSizeChange(1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Increase font size"
|
||||||
|
>
|
||||||
|
A+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={onShowResetConfirm}
|
||||||
|
type="button"
|
||||||
|
aria-label="Reset terminal"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-close"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
type="button"
|
||||||
|
title="Exit fullscreen (Esc)"
|
||||||
|
>
|
||||||
|
Exit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showResetConfirm && (
|
||||||
|
<div className="terminal-reset-confirm">
|
||||||
|
<div className="terminal-reset-confirm-content">
|
||||||
|
<p>
|
||||||
|
Reset terminal? This will kill the current shell session and
|
||||||
|
start fresh.
|
||||||
|
</p>
|
||||||
|
<div className="terminal-reset-confirm-buttons">
|
||||||
|
<button
|
||||||
|
className="terminal-reset-confirm-button cancel"
|
||||||
|
onClick={onHideResetConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-reset-confirm-button confirm"
|
||||||
|
onClick={() => {
|
||||||
|
onHideResetConfirm();
|
||||||
|
onReset();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TerminalSessionTabs
|
||||||
|
sessions={sessionInfos}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onRename={onRename}
|
||||||
|
isMobile={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="terminal-page-content">
|
||||||
|
{error && <div className="terminal-error-banner">{error}</div>}
|
||||||
|
{sessions
|
||||||
|
.filter((session) => session.id === activeSessionId)
|
||||||
|
.map((session) => (
|
||||||
|
<div key={session.id} className="terminal-instance active">
|
||||||
|
<TerminalComponent
|
||||||
|
ref={terminalRefs.current[session.id]}
|
||||||
|
instanceId={instanceId}
|
||||||
|
sessionId={session.id}
|
||||||
|
onClose={() => onClose(session.id)}
|
||||||
|
isMobile={false}
|
||||||
|
showControls={!isFullscreen}
|
||||||
|
onTerminalReady={onTerminalReady}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sessions.length === 0 && !loading && (
|
||||||
|
<div className="terminal-empty-state">
|
||||||
|
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TerminalComponent, type TerminalRef } from "./terminal";
|
||||||
|
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { SpecialKeysStrip } from "./special-keys-strip";
|
||||||
|
import { SpecialKeysPanel } from "./special-keys-panel";
|
||||||
|
import type { ModifierKey } from "../../../hooks/use-special-keys";
|
||||||
|
import type { TerminalSession } from "../../../api/terminal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instanceId: string;
|
||||||
|
sessions: TerminalSession[];
|
||||||
|
sessionInfos: TerminalSessionInfo[];
|
||||||
|
activeSessionId: string;
|
||||||
|
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
|
||||||
|
status: string;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
isKeyboardOpen: boolean;
|
||||||
|
keyboardHeight: number;
|
||||||
|
isVisible: boolean;
|
||||||
|
activeModifier: ModifierKey | null;
|
||||||
|
showSpecialKeysPanel: boolean;
|
||||||
|
onToggleHeader: () => void;
|
||||||
|
onNavigateBack: () => void;
|
||||||
|
onFontSizeChange: (delta: number) => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onRename: (id: string, name: string) => void;
|
||||||
|
onTerminalReady: (
|
||||||
|
sendData: (data: string) => void,
|
||||||
|
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
|
||||||
|
focusInput: () => void,
|
||||||
|
changeFontSize: (delta: number) => void,
|
||||||
|
) => void;
|
||||||
|
onSendKey: (data: string) => void;
|
||||||
|
onModifierChange: (mod: ModifierKey | null) => void;
|
||||||
|
onShowSpecialKeys: () => void;
|
||||||
|
onHideSpecialKeys: () => void;
|
||||||
|
onKeepFocus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileTerminalView: React.FC<Props> = ({
|
||||||
|
instanceId,
|
||||||
|
sessions,
|
||||||
|
sessionInfos,
|
||||||
|
activeSessionId,
|
||||||
|
terminalRefs,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
isKeyboardOpen,
|
||||||
|
keyboardHeight,
|
||||||
|
isVisible,
|
||||||
|
activeModifier,
|
||||||
|
showSpecialKeysPanel,
|
||||||
|
onToggleHeader,
|
||||||
|
onNavigateBack,
|
||||||
|
onFontSizeChange,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
onRename,
|
||||||
|
onTerminalReady,
|
||||||
|
onSendKey,
|
||||||
|
onModifierChange,
|
||||||
|
onShowSpecialKeys,
|
||||||
|
onHideSpecialKeys,
|
||||||
|
onKeepFocus,
|
||||||
|
}) => {
|
||||||
|
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="terminal-page mobile">
|
||||||
|
<div className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="mobile-terminal-toolbar">
|
||||||
|
<div className="mobile-terminal-toolbar-left">
|
||||||
|
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Back">
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-terminal-toolbar-center">
|
||||||
|
<span className="mobile-terminal-title">{activeSession?.name || "Terminal"}</span>
|
||||||
|
<span className={`mobile-terminal-status status-dot ${status}`} aria-label={`Connection status: ${status}`} />
|
||||||
|
</div>
|
||||||
|
<div className="mobile-terminal-toolbar-right">
|
||||||
|
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(-1)} type="button" aria-label="Decrease font size">
|
||||||
|
<span style={{ fontSize: "0.75rem" }}>A-</span>
|
||||||
|
</button>
|
||||||
|
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(1)} type="button" aria-label="Increase font size">
|
||||||
|
<span style={{ fontSize: "1rem" }}>A+</span>
|
||||||
|
</button>
|
||||||
|
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Exit terminal">
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-terminal-overlay-tabs">
|
||||||
|
<TerminalSessionTabs
|
||||||
|
sessions={sessionInfos}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onRename={onRename}
|
||||||
|
isMobile={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="terminal-page-content mobile-full"
|
||||||
|
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
|
||||||
|
onClick={onToggleHeader}
|
||||||
|
>
|
||||||
|
{error && <div className="terminal-error-banner">{error}</div>}
|
||||||
|
{sessions
|
||||||
|
.filter((session) => session.id === activeSessionId)
|
||||||
|
.map((session) => (
|
||||||
|
<div key={session.id} className="terminal-instance active">
|
||||||
|
<TerminalComponent
|
||||||
|
ref={terminalRefs.current[session.id]}
|
||||||
|
instanceId={instanceId}
|
||||||
|
sessionId={session.id}
|
||||||
|
onClose={() => onClose(session.id)}
|
||||||
|
isMobile={true}
|
||||||
|
showControls={false}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
onModifierChange={onModifierChange}
|
||||||
|
onTerminalReady={onTerminalReady}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sessions.length === 0 && !loading && (
|
||||||
|
<div className="terminal-empty-state">
|
||||||
|
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpecialKeysStrip
|
||||||
|
onSend={onSendKey}
|
||||||
|
isVisible={!showSpecialKeysPanel}
|
||||||
|
onMoreClick={onShowSpecialKeys}
|
||||||
|
onKeepFocus={onKeepFocus}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
onModifierChange={onModifierChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SpecialKeysPanel
|
||||||
|
onSend={onSendKey}
|
||||||
|
isOpen={showSpecialKeysPanel}
|
||||||
|
onClose={onHideSpecialKeys}
|
||||||
|
onKeepFocus={onKeepFocus}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
onModifierChange={onModifierChange}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { ManifestEditor } from "../tool/manifest-editor";
|
||||||
|
import type { ToolType } from "../../../api/tool-types";
|
||||||
|
import type { ToolDefinitionManifest } from "../../../api/tool-definitions";
|
||||||
|
|
||||||
|
export interface ToolTypeFormState {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
interface_type: "web" | "terminal";
|
||||||
|
requires_port: boolean;
|
||||||
|
default_port: string;
|
||||||
|
definition_type: "compose" | "dockerfile" | "manifest";
|
||||||
|
compose_template: string;
|
||||||
|
dockerfile_template: string;
|
||||||
|
readiness_command: string;
|
||||||
|
readiness_timeout: string;
|
||||||
|
readiness_interval: string;
|
||||||
|
required_variables: string;
|
||||||
|
startup_command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolTypeEditorPanelProps {
|
||||||
|
isCreating: boolean;
|
||||||
|
selectedToolType: ToolType | null;
|
||||||
|
form: ToolTypeFormState;
|
||||||
|
manifestData: Record<string, unknown> | null;
|
||||||
|
manifestDefinitionId: string | null;
|
||||||
|
baseDefinitions: ToolDefinitionManifest[];
|
||||||
|
toolTypeError: string | null;
|
||||||
|
toolTypeDirty: boolean;
|
||||||
|
onFormChange: (changes: Partial<ToolTypeFormState>) => void;
|
||||||
|
onManifestChange: (manifest: Record<string, unknown> | null) => void;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolTypeEditorPanel = ({
|
||||||
|
isCreating,
|
||||||
|
selectedToolType,
|
||||||
|
form,
|
||||||
|
manifestData,
|
||||||
|
manifestDefinitionId,
|
||||||
|
baseDefinitions,
|
||||||
|
toolTypeError,
|
||||||
|
toolTypeDirty,
|
||||||
|
onFormChange,
|
||||||
|
onManifestChange,
|
||||||
|
onSubmit,
|
||||||
|
onReset,
|
||||||
|
}: ToolTypeEditorPanelProps) => {
|
||||||
|
const hasSelection = isCreating || selectedToolType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
|
||||||
|
{!hasSelection ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
paddingTop: "4rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
|
||||||
|
<Icon name="code" size="lg" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>
|
||||||
|
Select a tool type
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
Choose a tool from the list to edit, or create a new one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
|
||||||
|
{isCreating
|
||||||
|
? "Create Tool Type"
|
||||||
|
: selectedToolType?.display_name}
|
||||||
|
</h1>
|
||||||
|
{!isCreating && (
|
||||||
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
|
{selectedToolType?.name} · {selectedToolType?.definition_type}{" "}
|
||||||
|
·{" "}
|
||||||
|
{selectedToolType?.interface_type === "web"
|
||||||
|
? `Port ${selectedToolType?.default_port}`
|
||||||
|
: "Terminal"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="stack"
|
||||||
|
style={{ gap: "1rem", maxWidth: "800px" }}
|
||||||
|
>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="definition-type">Definition Type</label>
|
||||||
|
<select
|
||||||
|
id="definition-type"
|
||||||
|
value={form.definition_type}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({
|
||||||
|
definition_type: e.target.value as
|
||||||
|
| "compose"
|
||||||
|
| "dockerfile"
|
||||||
|
| "manifest",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="form-input"
|
||||||
|
disabled={!isCreating}
|
||||||
|
>
|
||||||
|
<option value="compose">Docker Compose</option>
|
||||||
|
<option value="dockerfile">Dockerfile</option>
|
||||||
|
<option value="manifest">Manifest (Declarative)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="tool-type-name">Name *</label>
|
||||||
|
<input
|
||||||
|
id="tool-type-name"
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({ name: e.target.value });
|
||||||
|
}}
|
||||||
|
disabled={!isCreating}
|
||||||
|
placeholder="e.g., code-server"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="tool-type-display-name">
|
||||||
|
Display Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="tool-type-display-name"
|
||||||
|
type="text"
|
||||||
|
value={form.display_name}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({ display_name: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="e.g., VS Code Server"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="tool-type-description">Description</label>
|
||||||
|
<input
|
||||||
|
id="tool-type-description"
|
||||||
|
type="text"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({ description: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="Optional description"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="tool-type-category">Category</label>
|
||||||
|
<input
|
||||||
|
id="tool-type-category"
|
||||||
|
type="text"
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({ category: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="e.g., editor, notebook, ai-assistant"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="tool-type-interface">Interface Type</label>
|
||||||
|
<select
|
||||||
|
id="tool-type-interface"
|
||||||
|
value={form.interface_type}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value as "web" | "terminal";
|
||||||
|
onFormChange({
|
||||||
|
interface_type: value,
|
||||||
|
requires_port: value === "web",
|
||||||
|
default_port:
|
||||||
|
value === "web" ? form.default_port : "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="web">Web</option>
|
||||||
|
<option value="terminal">Terminal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.interface_type === "terminal" && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="tool-type-startup-command">
|
||||||
|
Startup Command
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="tool-type-startup-command"
|
||||||
|
type="text"
|
||||||
|
value={form.startup_command}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({ startup_command: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="e.g., cd /workspace && ls"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<small className="form-help">
|
||||||
|
Command to run before the interactive shell for each new
|
||||||
|
terminal session.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.requires_port && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="tool-type-default-port">
|
||||||
|
Default Port *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="tool-type-default-port"
|
||||||
|
type="number"
|
||||||
|
value={form.default_port}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({ default_port: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="e.g., 8443"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.definition_type === "manifest" ? (
|
||||||
|
<ManifestEditor
|
||||||
|
manifest={manifestData}
|
||||||
|
baseDefinitions={baseDefinitions}
|
||||||
|
onChange={(m) => {
|
||||||
|
onManifestChange(m);
|
||||||
|
}}
|
||||||
|
definitionId={manifestDefinitionId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="tool-type-template">
|
||||||
|
{form.definition_type === "compose"
|
||||||
|
? "Compose Template"
|
||||||
|
: "Dockerfile"}{" "}
|
||||||
|
*
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="tool-type-template"
|
||||||
|
value={
|
||||||
|
form.definition_type === "compose"
|
||||||
|
? form.compose_template
|
||||||
|
: form.dockerfile_template
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (form.definition_type === "compose") {
|
||||||
|
onFormChange({ compose_template: e.target.value });
|
||||||
|
} else {
|
||||||
|
onFormChange({ dockerfile_template: e.target.value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={12}
|
||||||
|
placeholder={
|
||||||
|
form.definition_type === "compose"
|
||||||
|
? "version: '3.8'\nservices:\n app:\n image: ..."
|
||||||
|
: "FROM node:18\nWORKDIR /app\n..."
|
||||||
|
}
|
||||||
|
className="form-input"
|
||||||
|
style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="readiness-command">
|
||||||
|
Readiness Probe Command
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="readiness-command"
|
||||||
|
type="text"
|
||||||
|
value={form.readiness_command}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({ readiness_command: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="e.g., curl -f http://localhost:8080"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
|
||||||
|
<input
|
||||||
|
id="readiness-timeout"
|
||||||
|
type="number"
|
||||||
|
value={form.readiness_timeout}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({ readiness_timeout: e.target.value });
|
||||||
|
}}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="readiness-interval">
|
||||||
|
Interval (seconds)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="readiness-interval"
|
||||||
|
type="number"
|
||||||
|
value={form.readiness_interval}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({ readiness_interval: e.target.value });
|
||||||
|
}}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Required Variables (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.required_variables}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFormChange({ required_variables: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="REPO_PATH, TOOL_NAME"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
|
||||||
|
|
||||||
|
<div className="dialog-actions" style={{ marginTop: "1rem" }}>
|
||||||
|
<button type="submit">
|
||||||
|
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
||||||
|
{isCreating ? "Create Tool Type" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
{toolTypeDirty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReset}
|
||||||
|
className="button-secondary"
|
||||||
|
>
|
||||||
|
<Icon name="cancel" size="sm" /> Discard
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
import type { ToolType } from "../../../api/tool-types";
|
||||||
|
|
||||||
|
interface ToolTypeListSidebarProps {
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
selectedToolTypeId: string | null;
|
||||||
|
onSelect: (toolType: ToolType) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolTypeListSidebar = ({
|
||||||
|
toolTypes,
|
||||||
|
selectedToolTypeId,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
}: ToolTypeListSidebarProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "280px",
|
||||||
|
minWidth: "280px",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "var(--panel)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Tool Workshop</h2>
|
||||||
|
<p
|
||||||
|
className="muted"
|
||||||
|
style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}
|
||||||
|
>
|
||||||
|
{toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
|
||||||
|
{toolTypes.map((toolType) => (
|
||||||
|
<button
|
||||||
|
key={toolType.id}
|
||||||
|
onClick={() => onSelect(toolType)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: "none",
|
||||||
|
background:
|
||||||
|
selectedToolTypeId === toolType.id
|
||||||
|
? "var(--brand)"
|
||||||
|
: "transparent",
|
||||||
|
color:
|
||||||
|
selectedToolTypeId === toolType.id ? "white" : "var(--ink)",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
transition: "background 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (selectedToolTypeId !== toolType.id) {
|
||||||
|
e.currentTarget.style.background = "#ece7df";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (selectedToolTypeId !== toolType.id) {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.9375rem",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toolType.display_name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
opacity: 0.8,
|
||||||
|
marginTop: "0.125rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toolType.category || "Uncategorized"} ·{" "}
|
||||||
|
{toolType.interface_type === "web"
|
||||||
|
? `Port ${toolType.default_port}`
|
||||||
|
: "Terminal"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(toolType.id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color:
|
||||||
|
selectedToolTypeId === toolType.id
|
||||||
|
? "rgba(255,255,255,0.8)"
|
||||||
|
: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0.25rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
className="delete-btn"
|
||||||
|
title="Delete tool type"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
|
||||||
|
<button
|
||||||
|
onClick={onCreate}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.75rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "2px dashed var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--brand)";
|
||||||
|
e.currentTarget.style.color = "var(--brand)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
|
e.currentTarget.style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> New Tool Type
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { MobileListView } from "../mobile/mobile-list-view";
|
||||||
|
import { MobileDetailView } from "../mobile/mobile-detail-view";
|
||||||
|
import { MobileEditView } from "../mobile/mobile-edit-view";
|
||||||
|
import { MobileFAB } from "../mobile/mobile-fab";
|
||||||
|
import type { ToolType } from "../../../api/tool-types";
|
||||||
|
import type { ToolTypeFormState } from "./ToolTypeEditorPanel";
|
||||||
|
|
||||||
|
export type MobileView = "list" | "detail" | "edit";
|
||||||
|
|
||||||
|
interface ToolWorkshopMobileViewProps {
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
selectedToolType: ToolType | null;
|
||||||
|
mobileView: MobileView;
|
||||||
|
isCreating: boolean;
|
||||||
|
toolTypeForm: ToolTypeFormState;
|
||||||
|
toolTypeError: string | null;
|
||||||
|
onViewChange: (view: MobileView) => void;
|
||||||
|
onSelect: (toolType: ToolType) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onFormChange: (changes: Partial<ToolTypeFormState>) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolWorkshopMobileView = ({
|
||||||
|
toolTypes,
|
||||||
|
selectedToolType,
|
||||||
|
mobileView,
|
||||||
|
isCreating,
|
||||||
|
toolTypeForm,
|
||||||
|
toolTypeError,
|
||||||
|
onViewChange,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
onFormChange,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: ToolWorkshopMobileViewProps) => {
|
||||||
|
if (mobileView === "list") {
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<div className="mobile-page-header">
|
||||||
|
<h1>Tool Workshop</h1>
|
||||||
|
<span className="muted">{toolTypes.length} tool types</span>
|
||||||
|
</div>
|
||||||
|
<MobileListView
|
||||||
|
items={toolTypes.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.display_name,
|
||||||
|
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
|
||||||
|
}))}
|
||||||
|
onItemClick={(id) => {
|
||||||
|
const toolType = toolTypes.find((t) => t.id === id);
|
||||||
|
if (toolType) {
|
||||||
|
onSelect(toolType);
|
||||||
|
onViewChange("detail");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
emptyMessage="No tool types yet"
|
||||||
|
/>
|
||||||
|
<MobileFAB
|
||||||
|
onClick={() => {
|
||||||
|
onCreate();
|
||||||
|
onViewChange("edit");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileView === "detail" && selectedToolType) {
|
||||||
|
return (
|
||||||
|
<MobileDetailView
|
||||||
|
title={selectedToolType.display_name}
|
||||||
|
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type} · ${selectedToolType.interface_type === "web" ? `Port ${selectedToolType.default_port}` : "Terminal"}`}
|
||||||
|
fields={[
|
||||||
|
{ label: "Name", value: selectedToolType.name },
|
||||||
|
{ label: "Display Name", value: selectedToolType.display_name },
|
||||||
|
{ label: "Description", value: selectedToolType.description },
|
||||||
|
{ label: "Category", value: selectedToolType.category },
|
||||||
|
{ label: "Interface Type", value: selectedToolType.interface_type },
|
||||||
|
{
|
||||||
|
label: "Requires Port",
|
||||||
|
value: selectedToolType.requires_port,
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
{ label: "Default Port", value: selectedToolType.default_port },
|
||||||
|
{
|
||||||
|
label: "Definition Type",
|
||||||
|
value: selectedToolType.definition_type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Startup Command",
|
||||||
|
value: selectedToolType.startup_command,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Readiness Command",
|
||||||
|
value: selectedToolType.readiness_probe?.command ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Readiness Timeout",
|
||||||
|
value: selectedToolType.readiness_probe?.timeout ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Readiness Interval",
|
||||||
|
value: selectedToolType.readiness_probe?.interval ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Required Variables",
|
||||||
|
value: selectedToolType.required_variables?.join(", ") ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Compose Template",
|
||||||
|
value: selectedToolType.compose_template,
|
||||||
|
type: "code",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dockerfile Template",
|
||||||
|
value: selectedToolType.dockerfile_template,
|
||||||
|
type: "code",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onEdit={() => {
|
||||||
|
onViewChange("edit");
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
void onDelete(selectedToolType.id);
|
||||||
|
onViewChange("list");
|
||||||
|
}}
|
||||||
|
onBack={() => {
|
||||||
|
onViewChange("list");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileView === "edit") {
|
||||||
|
return (
|
||||||
|
<MobileEditView
|
||||||
|
title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSave={() => {
|
||||||
|
onSubmit();
|
||||||
|
if (!toolTypeError) {
|
||||||
|
onViewChange("list");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isSaving={false}
|
||||||
|
>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.name}
|
||||||
|
onChange={(e) => onFormChange({ name: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="e.g., my-tool"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Display Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.display_name}
|
||||||
|
onChange={(e) => onFormChange({ display_name: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="e.g., My Tool"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={toolTypeForm.description}
|
||||||
|
onChange={(e) => onFormChange({ description: e.target.value })}
|
||||||
|
className="mobile-form-textarea"
|
||||||
|
placeholder="What does this tool do?"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Category</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.category}
|
||||||
|
onChange={(e) => onFormChange({ category: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="e.g., development"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Interface Type</label>
|
||||||
|
<select
|
||||||
|
value={toolTypeForm.interface_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFormChange({
|
||||||
|
interface_type: e.target.value as "web" | "terminal",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mobile-form-select"
|
||||||
|
>
|
||||||
|
<option value="web">Web</option>
|
||||||
|
<option value="terminal">Terminal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Requires Port</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toolTypeForm.requires_port}
|
||||||
|
onChange={(e) => onFormChange({ requires_port: e.target.checked })}
|
||||||
|
className="mobile-form-checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Default Port</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.default_port}
|
||||||
|
onChange={(e) => onFormChange({ default_port: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="e.g., 8080"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Definition Type</label>
|
||||||
|
<select
|
||||||
|
value={toolTypeForm.definition_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFormChange({
|
||||||
|
definition_type: e.target.value as "compose" | "dockerfile",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mobile-form-select"
|
||||||
|
>
|
||||||
|
<option value="compose">Compose</option>
|
||||||
|
<option value="dockerfile">Dockerfile</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Startup Command</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.startup_command}
|
||||||
|
onChange={(e) => onFormChange({ startup_command: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="Command to run on startup"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Readiness Command</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.readiness_command}
|
||||||
|
onChange={(e) => onFormChange({ readiness_command: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="e.g., curl -f http://localhost:8080/health"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Readiness Timeout</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.readiness_timeout}
|
||||||
|
onChange={(e) => onFormChange({ readiness_timeout: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Readiness Interval</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.readiness_interval}
|
||||||
|
onChange={(e) => onFormChange({ readiness_interval: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Required Variables</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.required_variables}
|
||||||
|
onChange={(e) => onFormChange({ required_variables: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="VAR1, VAR2, VAR3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{toolTypeForm.definition_type === "compose" && (
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Compose Template</label>
|
||||||
|
<textarea
|
||||||
|
value={toolTypeForm.compose_template}
|
||||||
|
onChange={(e) => onFormChange({ compose_template: e.target.value })}
|
||||||
|
className="mobile-form-textarea mobile-form-code"
|
||||||
|
placeholder="version: '3'"
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolTypeForm.definition_type === "dockerfile" && (
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Dockerfile Template</label>
|
||||||
|
<textarea
|
||||||
|
value={toolTypeForm.dockerfile_template}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFormChange({ dockerfile_template: e.target.value })
|
||||||
|
}
|
||||||
|
className="mobile-form-textarea mobile-form-code"
|
||||||
|
placeholder="FROM ubuntu:22.04"
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MobileEditView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<div className="mobile-page-header">
|
||||||
|
<h1>Tool Workshop</h1>
|
||||||
|
<span className="muted">{toolTypes.length} tool types</span>
|
||||||
|
</div>
|
||||||
|
<MobileListView
|
||||||
|
items={toolTypes.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.display_name,
|
||||||
|
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
|
||||||
|
}))}
|
||||||
|
onItemClick={(id) => {
|
||||||
|
const toolType = toolTypes.find((t) => t.id === id);
|
||||||
|
if (toolType) {
|
||||||
|
onSelect(toolType);
|
||||||
|
onViewChange("detail");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
emptyMessage="No tool types yet"
|
||||||
|
/>
|
||||||
|
<MobileFAB
|
||||||
|
onClick={() => {
|
||||||
|
onCreate();
|
||||||
|
onViewChange("edit");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { apiClient } from "../../../api/client";
|
||||||
|
import { EmptyState } from "../../data-states";
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import type { GitStatus } from "../../../api/git-repositories";
|
||||||
|
|
||||||
|
interface FileTreeEntry {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
path: string;
|
||||||
|
size?: number;
|
||||||
|
mode?: string;
|
||||||
|
last_commit?: {
|
||||||
|
hash: string;
|
||||||
|
message: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
repoId: string;
|
||||||
|
gitStatus: GitStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileBrowser = ({ projectId, repoId, gitStatus }: Props) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const branch = searchParams.get("branch") || "main";
|
||||||
|
const path = searchParams.get("path") || "";
|
||||||
|
|
||||||
|
const loadFiles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/files`,
|
||||||
|
{ params: { branch, path } }
|
||||||
|
);
|
||||||
|
setEntries(response.data.entries || []);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load files");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, repoId, branch, path]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFiles();
|
||||||
|
}, [loadFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRefresh = () => void loadFiles();
|
||||||
|
window.addEventListener("refresh-file-tree", handleRefresh);
|
||||||
|
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
||||||
|
}, [loadFiles]);
|
||||||
|
|
||||||
|
const handleEntryClick = (entry: FileTreeEntry) => {
|
||||||
|
if (entry.type === "directory") {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("path", entry.path);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
} else {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("file", entry.path);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateUp = () => {
|
||||||
|
if (!path) return;
|
||||||
|
const parentPath = path.split("/").slice(0, -1).join("/");
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
if (parentPath) {
|
||||||
|
newParams.set("path", parentPath);
|
||||||
|
} else {
|
||||||
|
newParams.delete("path");
|
||||||
|
}
|
||||||
|
setSearchParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileStatus = (filePath: string): string | null => {
|
||||||
|
if (!gitStatus) return null;
|
||||||
|
if (gitStatus.modified.includes(filePath)) return "modified";
|
||||||
|
if (gitStatus.added.includes(filePath)) return "added";
|
||||||
|
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
||||||
|
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p className="muted">Loading files...</p>;
|
||||||
|
if (error) return <p className="error-text">{error}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-tree">
|
||||||
|
{path && (
|
||||||
|
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
|
||||||
|
<Icon name="folder" size="sm" /> ..
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<EmptyState message="No files in this repository yet." />
|
||||||
|
)}
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
||||||
|
onClick={() => handleEntryClick(entry)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
|
||||||
|
{fileStatus && (
|
||||||
|
<span className={`file-status-indicator ${fileStatus}`}>
|
||||||
|
{fileStatus === "modified" && "M"}
|
||||||
|
{fileStatus === "added" && "A"}
|
||||||
|
{fileStatus === "deleted" && "D"}
|
||||||
|
{fileStatus === "untracked" && "?"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { FileBrowser } from "./FileBrowser";
|
||||||
|
import { FileEditor } from "../git/file-editor";
|
||||||
|
import { CommitPanel } from "../git/commit-panel";
|
||||||
|
import { GitToolbar } from "../git/git-toolbar";
|
||||||
|
import { InstanceList } from "../tool/instance-list";
|
||||||
|
import type { GitRepository, GitStatus } from "../../../api/git-repositories";
|
||||||
|
import type { ToolType } from "../../../api/tool-types";
|
||||||
|
import type { Project } from "../../../hooks/use-repo-workspace";
|
||||||
|
|
||||||
|
type MobileTab = "files" | "editor" | "git" | "terminal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
project: Project | null;
|
||||||
|
isMobile: boolean;
|
||||||
|
mobileTab: MobileTab;
|
||||||
|
selectedRepoId: string | null;
|
||||||
|
selectedRepo: GitRepository | undefined;
|
||||||
|
branches: string[];
|
||||||
|
currentBranch: string;
|
||||||
|
gitStatus: GitStatus | null;
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
repositories: GitRepository[];
|
||||||
|
onMobileTabChange: (tab: MobileTab) => void;
|
||||||
|
onRepoChange: (repoId: string) => void;
|
||||||
|
onBranchChange: (branch: string) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceLayout = ({
|
||||||
|
projectId,
|
||||||
|
project,
|
||||||
|
isMobile,
|
||||||
|
mobileTab,
|
||||||
|
selectedRepoId,
|
||||||
|
selectedRepo,
|
||||||
|
branches,
|
||||||
|
currentBranch,
|
||||||
|
gitStatus,
|
||||||
|
toolTypes,
|
||||||
|
repositories,
|
||||||
|
onMobileTabChange,
|
||||||
|
onRepoChange,
|
||||||
|
onBranchChange,
|
||||||
|
onRefresh,
|
||||||
|
}: Props) => {
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div className="mobile-workspace">
|
||||||
|
<div className="mobile-workspace-header">
|
||||||
|
<select
|
||||||
|
value={selectedRepoId || ""}
|
||||||
|
onChange={(e) => onRepoChange(e.target.value)}
|
||||||
|
className="mobile-repo-selector"
|
||||||
|
>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<option key={repo.id} value={repo.id}>
|
||||||
|
{repo.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedRepoId && (
|
||||||
|
<select
|
||||||
|
value={currentBranch}
|
||||||
|
onChange={(e) => onBranchChange(e.target.value)}
|
||||||
|
className="mobile-branch-selector"
|
||||||
|
>
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<option key={branch} value={branch}>
|
||||||
|
{branch}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-workspace-content">
|
||||||
|
{mobileTab === "files" && selectedRepoId && (
|
||||||
|
<FileBrowser projectId={projectId} repoId={selectedRepoId} gitStatus={gitStatus} />
|
||||||
|
)}
|
||||||
|
{mobileTab === "editor" && selectedRepoId && (
|
||||||
|
<FileEditor projectId={projectId} repoId={selectedRepoId} />
|
||||||
|
)}
|
||||||
|
{mobileTab === "git" && selectedRepoId && gitStatus && (
|
||||||
|
<div className="mobile-git-view">
|
||||||
|
<CommitPanel
|
||||||
|
projectId={projectId}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
modified={gitStatus.modified}
|
||||||
|
added={gitStatus.added}
|
||||||
|
deleted={gitStatus.deleted}
|
||||||
|
untracked={gitStatus.untracked}
|
||||||
|
onCommit={() => {
|
||||||
|
onRefresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mobileTab === "terminal" && selectedRepoId && (
|
||||||
|
<InstanceList
|
||||||
|
projectId={projectId}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
projectName={project?.name}
|
||||||
|
repoName={selectedRepo?.name}
|
||||||
|
toolTypes={toolTypes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-workspace-tabs">
|
||||||
|
<button
|
||||||
|
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
|
||||||
|
onClick={() => onMobileTabChange("files")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="folder" size="sm" />
|
||||||
|
<span>Files</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
|
||||||
|
onClick={() => onMobileTabChange("editor")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
<span>Editor</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
|
||||||
|
onClick={() => onMobileTabChange("git")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="branch" size="sm" />
|
||||||
|
<span>Git</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
|
||||||
|
onClick={() => onMobileTabChange("terminal")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="terminal" size="sm" />
|
||||||
|
<span>Terminal</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectedRepoId && (
|
||||||
|
<GitToolbar
|
||||||
|
projectId={projectId}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
currentBranch={currentBranch}
|
||||||
|
branches={branches}
|
||||||
|
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||||
|
isMirror={Boolean(selectedRepo?.is_mirror)}
|
||||||
|
onBranchChange={onBranchChange}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="workspace-layout">
|
||||||
|
<aside className="workspace-sidebar">
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select
|
||||||
|
value={selectedRepoId || ""}
|
||||||
|
onChange={(e) => onRepoChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<option key={repo.id} value={repo.id}>
|
||||||
|
{repo.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRepoId && (
|
||||||
|
<>
|
||||||
|
<FileBrowser projectId={projectId} repoId={selectedRepoId} gitStatus={gitStatus} />
|
||||||
|
{gitStatus && (
|
||||||
|
<CommitPanel
|
||||||
|
projectId={projectId}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
modified={gitStatus.modified}
|
||||||
|
added={gitStatus.added}
|
||||||
|
deleted={gitStatus.deleted}
|
||||||
|
untracked={gitStatus.untracked}
|
||||||
|
onCommit={onRefresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<InstanceList
|
||||||
|
projectId={projectId}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
projectName={project?.name}
|
||||||
|
repoName={selectedRepo?.name}
|
||||||
|
toolTypes={toolTypes}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="workspace-main">
|
||||||
|
{selectedRepoId && (
|
||||||
|
<FileEditor projectId={projectId} repoId={selectedRepoId} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { extractErrorMessage } from "../utils/errors";
|
||||||
|
import {
|
||||||
|
createConfigProfile,
|
||||||
|
deleteConfigProfile,
|
||||||
|
listConfigProfiles,
|
||||||
|
previewConfigProfile,
|
||||||
|
updateConfigProfile,
|
||||||
|
updateProfileIncludes,
|
||||||
|
type ConfigProfile,
|
||||||
|
type CreateConfigProfileRequest,
|
||||||
|
type ResolvedProfile,
|
||||||
|
} from "../api/config-profiles";
|
||||||
|
import { listProjects } from "../api/projects";
|
||||||
|
import { listToolTypes, type ToolType } from "../api/tool-types";
|
||||||
|
import type { ProjectWithRepos } from "../types";
|
||||||
|
|
||||||
|
type Status = "loading" | "ready" | "error";
|
||||||
|
type SaveStatus = "idle" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
|
const defaultForm: CreateConfigProfileRequest = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
env_vars: {},
|
||||||
|
runtime_hints: {},
|
||||||
|
mounts: [],
|
||||||
|
git_mounts: [],
|
||||||
|
files: {},
|
||||||
|
is_default: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfigProfiles = () => {
|
||||||
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
|
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
||||||
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||||
|
const [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
|
||||||
|
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const [profs, projs, types] = await Promise.all([
|
||||||
|
listConfigProfiles(),
|
||||||
|
listProjects(),
|
||||||
|
listToolTypes(),
|
||||||
|
]);
|
||||||
|
setProfiles(profs || []);
|
||||||
|
setProjects(projs || []);
|
||||||
|
setToolTypes(types || []);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData(defaultForm);
|
||||||
|
setIncludedProfileIds([]);
|
||||||
|
setError(null);
|
||||||
|
setSaveStatus("idle");
|
||||||
|
setPreviewData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const populateForm = (profile: ConfigProfile) => {
|
||||||
|
setFormData({
|
||||||
|
name: profile.name,
|
||||||
|
description: profile.description || undefined,
|
||||||
|
project_id: profile.project_id || undefined,
|
||||||
|
tool_type_id: profile.tool_type_id || undefined,
|
||||||
|
env_vars: profile.env_vars,
|
||||||
|
runtime_hints: profile.runtime_hints,
|
||||||
|
mounts: profile.mounts,
|
||||||
|
git_mounts: profile.git_mounts || [],
|
||||||
|
files: profile.files,
|
||||||
|
is_default: profile.is_default,
|
||||||
|
});
|
||||||
|
setIncludedProfileIds(
|
||||||
|
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id),
|
||||||
|
);
|
||||||
|
setError(null);
|
||||||
|
setSaveStatus("idle");
|
||||||
|
setPreviewData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectProfile = (profile: ConfigProfile | null) => {
|
||||||
|
if (profile) {
|
||||||
|
setSelectedProfileId(profile.id);
|
||||||
|
setIsCreating(false);
|
||||||
|
populateForm(profile);
|
||||||
|
} else {
|
||||||
|
setSelectedProfileId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
setSelectedProfileId(null);
|
||||||
|
setIsCreating(true);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIncludedProfile = (id: string): ConfigProfile | undefined =>
|
||||||
|
profiles.find((p) => p.id === id);
|
||||||
|
|
||||||
|
const getScopeLabel = (profile: ConfigProfile): string => {
|
||||||
|
if (profile.project_id && profile.tool_type_id) return "Project + Tool";
|
||||||
|
if (profile.project_id) return "Project";
|
||||||
|
if (profile.tool_type_id) return "Tool";
|
||||||
|
return "Global";
|
||||||
|
};
|
||||||
|
|
||||||
|
const wouldCreateCycle = (
|
||||||
|
profileId: string,
|
||||||
|
targetId: string,
|
||||||
|
visited = new Set<string>(),
|
||||||
|
): boolean => {
|
||||||
|
if (visited.has(targetId)) return true;
|
||||||
|
const target = getIncludedProfile(targetId);
|
||||||
|
if (!target) return false;
|
||||||
|
const nextVisited = new Set(visited);
|
||||||
|
nextVisited.add(targetId);
|
||||||
|
for (const inc of target.includes) {
|
||||||
|
if (
|
||||||
|
inc.included_profile_id === profileId ||
|
||||||
|
wouldCreateCycle(profileId, inc.included_profile_id, nextVisited)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableProfilesForInclude = (): ConfigProfile[] => {
|
||||||
|
const currentId = selectedProfile?.id;
|
||||||
|
if (!currentId) return [];
|
||||||
|
return profiles.filter((p) => {
|
||||||
|
if (p.id === currentId) return false;
|
||||||
|
if (includedProfileIds.includes(p.id)) return false;
|
||||||
|
if (wouldCreateCycle(currentId, p.id)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addInclude = (profileId: string) => {
|
||||||
|
setIncludedProfileIds((prev) => [...prev, profileId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeInclude = (index: number) => {
|
||||||
|
setIncludedProfileIds((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||||
|
e.dataTransfer.setData("text/plain", String(index));
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
setDragOverIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const dragIndex = Number(e.dataTransfer.getData("text/plain"));
|
||||||
|
if (dragIndex === dropIndex) {
|
||||||
|
setDragOverIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIncludedProfileIds((prev) => {
|
||||||
|
const newOrder = [...prev];
|
||||||
|
const [removed] = newOrder.splice(dragIndex, 1);
|
||||||
|
newOrder.splice(dropIndex, 0, removed);
|
||||||
|
return newOrder;
|
||||||
|
});
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSaveStatus("saving");
|
||||||
|
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
setError("Name is required");
|
||||||
|
setSaveStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isCreating) {
|
||||||
|
const newProfile = await createConfigProfile(formData);
|
||||||
|
if (includedProfileIds.length > 0) {
|
||||||
|
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
|
||||||
|
}
|
||||||
|
setIsCreating(false);
|
||||||
|
setSelectedProfileId(newProfile.id);
|
||||||
|
setSaveStatus("saved");
|
||||||
|
await loadData();
|
||||||
|
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
|
||||||
|
if (refreshed) populateForm(refreshed);
|
||||||
|
} else if (selectedProfile) {
|
||||||
|
await updateConfigProfile(selectedProfile.id, formData);
|
||||||
|
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
|
||||||
|
setSaveStatus("saved");
|
||||||
|
await loadData();
|
||||||
|
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
|
||||||
|
if (refreshed) populateForm(refreshed);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractErrorMessage(err));
|
||||||
|
setSaveStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
|
||||||
|
try {
|
||||||
|
await deleteConfigProfile(id);
|
||||||
|
if (selectedProfileId === id) {
|
||||||
|
setSelectedProfileId(null);
|
||||||
|
setIsCreating(false);
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
alert("Failed to delete config profile");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = async (id: string) => {
|
||||||
|
try {
|
||||||
|
setPreviewingId(id);
|
||||||
|
const data = await previewConfigProfile(id);
|
||||||
|
setPreviewData(data);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to preview config profile");
|
||||||
|
} finally {
|
||||||
|
setPreviewingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFormField = <K extends keyof CreateConfigProfileRequest>(
|
||||||
|
key: K,
|
||||||
|
value: CreateConfigProfileRequest[K],
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEnvVar = () => {
|
||||||
|
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const envVars = { ...prev.env_vars };
|
||||||
|
if (oldKey !== newKey) delete envVars[oldKey];
|
||||||
|
envVars[newKey] = value;
|
||||||
|
return { ...prev, env_vars: envVars };
|
||||||
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEnvVar = (key: string) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const envVars = { ...prev.env_vars };
|
||||||
|
delete envVars[key];
|
||||||
|
return { ...prev, env_vars: envVars };
|
||||||
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFile = () => {
|
||||||
|
setFormData((prev) => ({ ...prev, files: { ...prev.files, "": "" } }));
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFile = (oldPath: string, newPath: string, content: string) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const files = { ...prev.files };
|
||||||
|
if (oldPath !== newPath) delete files[oldPath];
|
||||||
|
files[newPath] = content;
|
||||||
|
return { ...prev, files };
|
||||||
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (path: string) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const files = { ...prev.files };
|
||||||
|
delete files[path];
|
||||||
|
return { ...prev, files };
|
||||||
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMount = () => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
|
||||||
|
}));
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const mounts = [...(prev.mounts || [])];
|
||||||
|
mounts[index] = { ...mounts[index], ...updates };
|
||||||
|
return { ...prev, mounts };
|
||||||
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMount = (index: number) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const mounts = [...(prev.mounts || [])];
|
||||||
|
mounts.splice(index, 1);
|
||||||
|
return { ...prev, mounts };
|
||||||
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMountFile = (mountIndex: number) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const mounts = [...(prev.mounts || [])];
|
||||||
|
mounts[mountIndex] = {
|
||||||
|
...mounts[mountIndex],
|
||||||
|
files: { ...mounts[mountIndex].files, "": "" },
|
||||||
|
};
|
||||||
|
return { ...prev, mounts };
|
||||||
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMountFile = (
|
||||||
|
mountIndex: number,
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string,
|
||||||
|
content: string,
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const mounts = [...(prev.mounts || [])];
|
||||||
|
const files = { ...mounts[mountIndex].files };
|
||||||
|
if (oldPath !== newPath) delete files[oldPath];
|
||||||
|
files[newPath] = content;
|
||||||
|
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||||
|
return { ...prev, mounts };
|
||||||
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMountFile = (mountIndex: number, path: string) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const mounts = [...(prev.mounts || [])];
|
||||||
|
const files = { ...mounts[mountIndex].files };
|
||||||
|
delete files[path];
|
||||||
|
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||||
|
return { ...prev, mounts };
|
||||||
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
profiles,
|
||||||
|
projects,
|
||||||
|
toolTypes,
|
||||||
|
selectedProfile,
|
||||||
|
selectedProfileId,
|
||||||
|
isCreating,
|
||||||
|
saveStatus,
|
||||||
|
error,
|
||||||
|
previewData,
|
||||||
|
previewingId,
|
||||||
|
formData,
|
||||||
|
includedProfileIds,
|
||||||
|
dragOverIndex,
|
||||||
|
loadData,
|
||||||
|
handleSelectProfile,
|
||||||
|
handleCreateNew,
|
||||||
|
handleSubmit,
|
||||||
|
handleDelete,
|
||||||
|
handlePreview,
|
||||||
|
updateFormField,
|
||||||
|
addEnvVar,
|
||||||
|
updateEnvVar,
|
||||||
|
removeEnvVar,
|
||||||
|
addFile,
|
||||||
|
updateFile,
|
||||||
|
removeFile,
|
||||||
|
addMount,
|
||||||
|
updateMount,
|
||||||
|
removeMount,
|
||||||
|
addMountFile,
|
||||||
|
updateMountFile,
|
||||||
|
removeMountFile,
|
||||||
|
getIncludedProfile,
|
||||||
|
getScopeLabel,
|
||||||
|
availableProfilesForInclude,
|
||||||
|
addInclude,
|
||||||
|
removeInclude,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
setPreviewData,
|
||||||
|
setFormData,
|
||||||
|
setSaveStatus,
|
||||||
|
setIncludedProfileIds,
|
||||||
|
populateForm,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
createProject,
|
||||||
|
deleteProject,
|
||||||
|
listProjects,
|
||||||
|
updateProject,
|
||||||
|
type ProjectCreateInput,
|
||||||
|
type ProjectUpdateInput,
|
||||||
|
} from "../api/projects";
|
||||||
|
import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
|
||||||
|
import { useAsyncData } from "./use-async-data";
|
||||||
|
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
||||||
|
|
||||||
|
type DialogMode = "none" | "create" | "edit";
|
||||||
|
|
||||||
|
export const useProjects = () => {
|
||||||
|
const {
|
||||||
|
data: projects,
|
||||||
|
status,
|
||||||
|
reload,
|
||||||
|
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
|
||||||
|
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||||
|
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [formDescription, setFormDescription] = useState("");
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [expandedProject, setExpandedProject] = useState<string | null>(null);
|
||||||
|
const [creatingWorkspace, setCreatingWorkspace] = useState<{
|
||||||
|
projectId: string;
|
||||||
|
repoId: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const safeProjects = projects ?? [];
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setFormName("");
|
||||||
|
setFormDescription("");
|
||||||
|
setFormError(null);
|
||||||
|
setEditingProject(null);
|
||||||
|
setDialogMode("create");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (project: ProjectWithRepos) => {
|
||||||
|
setFormName(project.name);
|
||||||
|
setFormDescription(project.description ?? "");
|
||||||
|
setFormError(null);
|
||||||
|
setEditingProject(project);
|
||||||
|
setDialogMode("edit");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogMode("none");
|
||||||
|
setEditingProject(null);
|
||||||
|
setFormError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
if (!formName.trim()) {
|
||||||
|
setFormError("Project name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dialogMode === "create") {
|
||||||
|
const input: ProjectCreateInput = {
|
||||||
|
name: formName.trim(),
|
||||||
|
description: formDescription.trim() || null,
|
||||||
|
};
|
||||||
|
await createProject(input);
|
||||||
|
} else if (dialogMode === "edit" && editingProject) {
|
||||||
|
const input: ProjectUpdateInput = {
|
||||||
|
name: formName.trim(),
|
||||||
|
description: formDescription.trim() || null,
|
||||||
|
};
|
||||||
|
await updateProject(editingProject.id, input);
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
setFormError("Failed to save project");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (projectId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteProject(projectId);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncWorkspace = async (
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspace: WorkspaceSummary,
|
||||||
|
) => {
|
||||||
|
setWorkspaceLoading(workspace.id);
|
||||||
|
try {
|
||||||
|
await syncWorkspace(projectId, repoId, workspace.id);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "Failed to sync workspace");
|
||||||
|
} finally {
|
||||||
|
setWorkspaceLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteWorkspace = async (workspace: WorkspaceSummary) => {
|
||||||
|
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
|
||||||
|
setWorkspaceLoading(workspace.id);
|
||||||
|
try {
|
||||||
|
await deleteWorkspace(workspace.id);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "Failed to delete workspace");
|
||||||
|
} finally {
|
||||||
|
setWorkspaceLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: safeProjects,
|
||||||
|
status,
|
||||||
|
reload,
|
||||||
|
dialogMode,
|
||||||
|
formName,
|
||||||
|
setFormName,
|
||||||
|
formDescription,
|
||||||
|
setFormDescription,
|
||||||
|
formError,
|
||||||
|
deleteConfirmId,
|
||||||
|
setDeleteConfirmId,
|
||||||
|
expandedProject,
|
||||||
|
setExpandedProject,
|
||||||
|
creatingWorkspace,
|
||||||
|
setCreatingWorkspace,
|
||||||
|
workspaceLoading,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
closeDialog,
|
||||||
|
handleSubmit,
|
||||||
|
handleDelete,
|
||||||
|
handleSyncWorkspace,
|
||||||
|
handleDeleteWorkspace,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { apiClient } from "../api/client";
|
||||||
|
import {
|
||||||
|
getRepositoryStatus,
|
||||||
|
listRepositories,
|
||||||
|
type GitRepository,
|
||||||
|
type GitStatus,
|
||||||
|
} from "../api/git-repositories";
|
||||||
|
import { listToolTypes, type ToolType } from "../api/tool-types";
|
||||||
|
|
||||||
|
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRepoWorkspace = () => {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
|
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
|
||||||
|
searchParams.get("repo")
|
||||||
|
);
|
||||||
|
const [branches, setBranches] = useState<string[]>([]);
|
||||||
|
const [currentBranch, setCurrentBranch] = useState<string>("main");
|
||||||
|
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
|
||||||
|
const loadProject = useCallback(async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/projects/${projectId}`);
|
||||||
|
setProject(response.data);
|
||||||
|
} catch {
|
||||||
|
setProject(null);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const loadRepositories = useCallback(async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const data = await listRepositories(projectId);
|
||||||
|
setRepositories(data);
|
||||||
|
if (data.length === 0) {
|
||||||
|
setStatus("empty");
|
||||||
|
} else {
|
||||||
|
setStatus("ready");
|
||||||
|
if (!selectedRepoId) {
|
||||||
|
setSelectedRepoId(data[0].id);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("repo", data[0].id);
|
||||||
|
setSearchParams(newParams, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRepositories([]);
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
const loadBranches = useCallback(async () => {
|
||||||
|
if (!projectId || !selectedRepoId) return;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
|
||||||
|
);
|
||||||
|
const branchList = response.data.branches.map((b: { name: string }) => b.name);
|
||||||
|
setBranches(branchList);
|
||||||
|
const defaultBranch = response.data.default_branch;
|
||||||
|
if (defaultBranch) setCurrentBranch(defaultBranch);
|
||||||
|
} catch {
|
||||||
|
setBranches([]);
|
||||||
|
}
|
||||||
|
}, [projectId, selectedRepoId]);
|
||||||
|
|
||||||
|
const loadGitStatus = useCallback(async () => {
|
||||||
|
if (!projectId || !selectedRepoId) return;
|
||||||
|
try {
|
||||||
|
const data = await getRepositoryStatus(projectId, selectedRepoId);
|
||||||
|
setGitStatus(data);
|
||||||
|
} catch {
|
||||||
|
setGitStatus(null);
|
||||||
|
}
|
||||||
|
}, [projectId, selectedRepoId]);
|
||||||
|
|
||||||
|
const loadToolTypes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await listToolTypes();
|
||||||
|
setToolTypes(data);
|
||||||
|
} catch {
|
||||||
|
setToolTypes([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProject();
|
||||||
|
void loadRepositories();
|
||||||
|
void loadToolTypes();
|
||||||
|
}, [loadProject, loadRepositories, loadToolTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBranches();
|
||||||
|
void loadGitStatus();
|
||||||
|
}, [loadBranches, loadGitStatus]);
|
||||||
|
|
||||||
|
const handleRepoChange = (repoId: string) => {
|
||||||
|
setSelectedRepoId(repoId);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("repo", repoId);
|
||||||
|
newParams.delete("branch");
|
||||||
|
newParams.delete("path");
|
||||||
|
setSearchParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBranchChange = (branch: string) => {
|
||||||
|
setCurrentBranch(branch);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("branch", branch);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
project,
|
||||||
|
status,
|
||||||
|
repositories,
|
||||||
|
selectedRepoId,
|
||||||
|
selectedRepo,
|
||||||
|
branches,
|
||||||
|
currentBranch,
|
||||||
|
gitStatus,
|
||||||
|
toolTypes,
|
||||||
|
handleRepoChange,
|
||||||
|
handleBranchChange,
|
||||||
|
loadGitStatus,
|
||||||
|
loadBranches,
|
||||||
|
loadRepositories,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh-keys";
|
||||||
|
import { useAsyncData } from "./use-async-data";
|
||||||
|
|
||||||
|
export const useSSHKeys = () => {
|
||||||
|
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
||||||
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||||
|
const [signatures, setSignatures] = useState<Record<string, string>>({});
|
||||||
|
const [signing, setSigning] = useState<Record<string, boolean>>({});
|
||||||
|
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
|
||||||
|
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||||
|
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||||
|
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||||
|
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const safeKeys = keys ?? [];
|
||||||
|
|
||||||
|
async function handleGenerate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newKeyName.trim()) return;
|
||||||
|
try {
|
||||||
|
setGenerating(true);
|
||||||
|
await createSSHKey({ name: newKeyName.trim() });
|
||||||
|
setNewKeyName("");
|
||||||
|
await loadKeys();
|
||||||
|
} catch {
|
||||||
|
setMutationError("Failed to generate SSH key");
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(keyId: string) {
|
||||||
|
if (!confirm("Are you sure you want to delete this SSH key?")) return;
|
||||||
|
try {
|
||||||
|
await deleteSSHKey(keyId);
|
||||||
|
await loadKeys();
|
||||||
|
} catch {
|
||||||
|
setMutationError("Failed to delete SSH key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(text: string) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSign(keyId: string) {
|
||||||
|
const payload = signPayloads[keyId];
|
||||||
|
if (!payload?.trim()) return;
|
||||||
|
try {
|
||||||
|
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
||||||
|
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||||
|
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||||
|
setMutationError(null);
|
||||||
|
} catch {
|
||||||
|
setMutationError("Failed to sign payload");
|
||||||
|
} finally {
|
||||||
|
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify(keyId: string) {
|
||||||
|
const payload = verifyPayloads[keyId];
|
||||||
|
const signature = verifySignatures[keyId];
|
||||||
|
if (!payload?.trim() || !signature?.trim()) return;
|
||||||
|
try {
|
||||||
|
setVerifying((prev) => ({ ...prev, [keyId]: true }));
|
||||||
|
const result = await verifySignature(keyId, {
|
||||||
|
payload: payload.trim(),
|
||||||
|
signature: signature.trim(),
|
||||||
|
});
|
||||||
|
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||||
|
setMutationError(null);
|
||||||
|
} catch {
|
||||||
|
setMutationError("Failed to verify signature");
|
||||||
|
} finally {
|
||||||
|
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
keys: safeKeys,
|
||||||
|
status,
|
||||||
|
loadKeys,
|
||||||
|
newKeyName,
|
||||||
|
setNewKeyName,
|
||||||
|
generating,
|
||||||
|
mutationError,
|
||||||
|
setMutationError,
|
||||||
|
signPayloads,
|
||||||
|
setSignPayloads,
|
||||||
|
signatures,
|
||||||
|
signing,
|
||||||
|
verifyPayloads,
|
||||||
|
setVerifyPayloads,
|
||||||
|
verifySignatures,
|
||||||
|
setVerifySignatures,
|
||||||
|
verifyResults,
|
||||||
|
verifying,
|
||||||
|
handleGenerate,
|
||||||
|
handleDelete,
|
||||||
|
copyToClipboard,
|
||||||
|
handleSign,
|
||||||
|
handleVerify,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import type { TerminalRef } from "../components/features/terminal/terminal";
|
||||||
|
import type { TerminalSessionInfo } from "../components/features/terminal/terminal-session-tabs";
|
||||||
|
import { useMobileViewport } from "./use-mobile-viewport";
|
||||||
|
import { useAutoHide } from "./use-auto-hide";
|
||||||
|
import { useVirtualKeyboard } from "./use-virtual-keyboard";
|
||||||
|
import { useTerminalSessions } from "./use-terminal-sessions";
|
||||||
|
import type { TerminalSession } from "../api/terminal";
|
||||||
|
import type { ModifierKey } from "./use-special-keys";
|
||||||
|
|
||||||
|
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
||||||
|
sessions.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
status: s.status as TerminalSessionInfo["status"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
type TerminalStatus =
|
||||||
|
| "connecting"
|
||||||
|
| "connected"
|
||||||
|
| "disconnected"
|
||||||
|
| "error"
|
||||||
|
| "resetting";
|
||||||
|
|
||||||
|
export const useTerminalPage = () => {
|
||||||
|
const { instanceId } = useParams<{ instanceId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
||||||
|
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||||
|
|
||||||
|
const [terminalStatuses, setTerminalStatuses] = useState<
|
||||||
|
Record<string, TerminalStatus>
|
||||||
|
>({});
|
||||||
|
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
||||||
|
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
||||||
|
const focusInputRef = useRef<(() => void) | null>(null);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
||||||
|
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
||||||
|
const { isOpen: isKeyboardOpen, height: keyboardHeight } = useVirtualKeyboard();
|
||||||
|
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
setActiveSessionId,
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
renameSession,
|
||||||
|
resetSession,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useTerminalSessions(instanceId ?? "");
|
||||||
|
|
||||||
|
// Auto-create default session
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||||
|
void createSession("Session 1");
|
||||||
|
}
|
||||||
|
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||||
|
|
||||||
|
// Sync refs with sessions
|
||||||
|
useEffect(() => {
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!terminalRefs.current[session.id]) {
|
||||||
|
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const currentIds = new Set(sessions.map((s) => s.id));
|
||||||
|
for (const id of Object.keys(terminalRefs.current)) {
|
||||||
|
if (!currentIds.has(id)) {
|
||||||
|
delete terminalRefs.current[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
|
// Fit and focus active terminal
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||||
|
const ref = terminalRefs.current[activeSessionId];
|
||||||
|
let raf1 = 0;
|
||||||
|
let raf2 = 0;
|
||||||
|
raf1 = requestAnimationFrame(() => {
|
||||||
|
raf2 = requestAnimationFrame(() => {
|
||||||
|
ref.current?.fit();
|
||||||
|
ref.current?.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf1);
|
||||||
|
cancelAnimationFrame(raf2);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [activeSessionId]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||||
|
if (!isAltShift) return;
|
||||||
|
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case "n":
|
||||||
|
e.preventDefault();
|
||||||
|
if (sessions.length < 5) {
|
||||||
|
void createSession(`Session ${sessions.length + 1}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "w":
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeSessionId && window.confirm("Close this terminal session?")) {
|
||||||
|
void closeSession(activeSessionId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "arrowleft":
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeSessionId) {
|
||||||
|
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||||
|
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "arrowright":
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeSessionId) {
|
||||||
|
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||||
|
if (idx < sessions.length - 1) setActiveSessionId(sessions[idx + 1].id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "r":
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeSessionId) void resetSession(activeSessionId);
|
||||||
|
break;
|
||||||
|
case "f":
|
||||||
|
e.preventDefault();
|
||||||
|
setIsFullscreen((prev) => !prev);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
resetSession,
|
||||||
|
setActiveSessionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Keep screen awake
|
||||||
|
useEffect(() => {
|
||||||
|
let wakeLock: WakeLockSentinel | null = null;
|
||||||
|
const requestWakeLock = async () => {
|
||||||
|
try {
|
||||||
|
if ("wakeLock" in navigator) {
|
||||||
|
wakeLock = await navigator.wakeLock.request("screen");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void requestWakeLock();
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible") void requestWakeLock();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
wakeLock?.release().catch(() => {});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Lock page scroll on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
document.documentElement.classList.add("terminal-page-open");
|
||||||
|
document.body.classList.add("terminal-page-open");
|
||||||
|
return () => {
|
||||||
|
document.documentElement.classList.remove("terminal-page-open");
|
||||||
|
document.body.classList.remove("terminal-page-open");
|
||||||
|
};
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
const handleFullscreenClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
if (!isFullscreen) return;
|
||||||
|
const target = e.target as Node;
|
||||||
|
const current = e.currentTarget as HTMLElement;
|
||||||
|
const content = current.querySelector(".terminal-page-content");
|
||||||
|
const header = current.querySelector(".terminal-fullscreen-header");
|
||||||
|
if (content?.contains(target) || header?.contains(target)) return;
|
||||||
|
setIsFullscreen(false);
|
||||||
|
},
|
||||||
|
[isFullscreen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(sessionId: string) => setActiveSessionId(sessionId),
|
||||||
|
[setActiveSessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(
|
||||||
|
async (sessionId: string) => closeSession(sessionId),
|
||||||
|
[closeSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
void createSession(`Session ${sessions.length + 1}`);
|
||||||
|
}, [createSession, sessions.length]);
|
||||||
|
|
||||||
|
const handleRename = useCallback(
|
||||||
|
(sessionId: string, newName: string) => {
|
||||||
|
void renameSession(sessionId, newName);
|
||||||
|
},
|
||||||
|
[renameSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTerminalReady = useCallback(
|
||||||
|
(
|
||||||
|
sendData: (data: string) => void,
|
||||||
|
status: TerminalStatus,
|
||||||
|
focusInput: () => void,
|
||||||
|
changeFontSize: (delta: number) => void,
|
||||||
|
) => {
|
||||||
|
setTerminalStatuses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[activeSessionId ?? "default"]: status,
|
||||||
|
}));
|
||||||
|
sendDataRef.current = sendData;
|
||||||
|
focusInputRef.current = focusInput;
|
||||||
|
changeFontSizeRef.current = changeFontSize;
|
||||||
|
},
|
||||||
|
[activeSessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFontSizeChange = useCallback((delta: number) => {
|
||||||
|
changeFontSizeRef.current?.(delta);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSendKey = useCallback((data: string) => {
|
||||||
|
sendDataRef.current?.(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||||
|
terminalRefs.current[activeSessionId].current?.reset();
|
||||||
|
}
|
||||||
|
}, [activeSessionId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
instanceId,
|
||||||
|
navigate,
|
||||||
|
isMobile,
|
||||||
|
isFullscreen,
|
||||||
|
setIsFullscreen,
|
||||||
|
terminalRefs,
|
||||||
|
headerAutoHide,
|
||||||
|
terminalStatuses,
|
||||||
|
sendDataRef,
|
||||||
|
focusInputRef,
|
||||||
|
changeFontSizeRef,
|
||||||
|
showResetConfirm,
|
||||||
|
setShowResetConfirm,
|
||||||
|
showSpecialKeysPanel,
|
||||||
|
setShowSpecialKeysPanel,
|
||||||
|
activeModifier,
|
||||||
|
setActiveModifier,
|
||||||
|
isKeyboardOpen,
|
||||||
|
keyboardHeight,
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
setActiveSessionId,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
handleFullscreenClick,
|
||||||
|
handleSelect,
|
||||||
|
handleClose,
|
||||||
|
handleCreate,
|
||||||
|
handleRename,
|
||||||
|
handleTerminalReady,
|
||||||
|
handleFontSizeChange,
|
||||||
|
handleSendKey,
|
||||||
|
handleReset,
|
||||||
|
sessionInfos: SESSIONS_TO_INFO(sessions),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { extractErrorMessage } from "../utils/errors";
|
||||||
|
import {
|
||||||
|
createToolType,
|
||||||
|
deleteToolType,
|
||||||
|
listToolTypes,
|
||||||
|
updateToolType,
|
||||||
|
type CreateToolTypeRequest,
|
||||||
|
type ReadinessProbe,
|
||||||
|
type ToolType,
|
||||||
|
type UpdateToolTypeRequest,
|
||||||
|
} from "../api/tool-types";
|
||||||
|
import {
|
||||||
|
createToolDefinition,
|
||||||
|
getToolDefinition,
|
||||||
|
listToolDefinitions,
|
||||||
|
updateToolDefinition,
|
||||||
|
} from "../api/tool-definitions";
|
||||||
|
import type { ToolTypeFormState } from "../components/features/tool-workshop/ToolTypeEditorPanel";
|
||||||
|
|
||||||
|
type Status = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
const defaultForm: ToolTypeFormState = {
|
||||||
|
name: "",
|
||||||
|
display_name: "",
|
||||||
|
description: "",
|
||||||
|
category: "",
|
||||||
|
interface_type: "web",
|
||||||
|
requires_port: true,
|
||||||
|
default_port: "",
|
||||||
|
definition_type: "compose",
|
||||||
|
compose_template: "",
|
||||||
|
dockerfile_template: "",
|
||||||
|
readiness_command: "",
|
||||||
|
readiness_timeout: "30",
|
||||||
|
readiness_interval: "2",
|
||||||
|
required_variables: "",
|
||||||
|
startup_command: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useToolWorkshop = () => {
|
||||||
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
const [baseDefinitions, setBaseDefinitions] = useState<
|
||||||
|
Awaited<ReturnType<typeof listToolDefinitions>>
|
||||||
|
>([]);
|
||||||
|
const [manifestData, setManifestData] = useState<Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null>(null);
|
||||||
|
const [manifestDefinitionId, setManifestDefinitionId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [selectedToolTypeId, setSelectedToolTypeId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [toolTypeForm, setToolTypeForm] = useState<ToolTypeFormState>(defaultForm);
|
||||||
|
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
||||||
|
const [toolTypeDirty, setToolTypeDirty] = useState(false);
|
||||||
|
|
||||||
|
const selectedToolType =
|
||||||
|
(toolTypes || []).find((t) => t.id === selectedToolTypeId) || null;
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const [types, defs] = await Promise.all([
|
||||||
|
listToolTypes(),
|
||||||
|
listToolDefinitions(),
|
||||||
|
]);
|
||||||
|
setToolTypes(types || []);
|
||||||
|
setBaseDefinitions((defs || []).filter((d) => d.is_base));
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const resetToolTypeForm = () => {
|
||||||
|
setToolTypeForm(defaultForm);
|
||||||
|
setToolTypeError(null);
|
||||||
|
setToolTypeDirty(false);
|
||||||
|
setManifestData(null);
|
||||||
|
setManifestDefinitionId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const populateToolTypeForm = async (toolType: ToolType) => {
|
||||||
|
setToolTypeForm({
|
||||||
|
name: toolType.name,
|
||||||
|
display_name: toolType.display_name,
|
||||||
|
description: toolType.description || "",
|
||||||
|
category: toolType.category || "",
|
||||||
|
interface_type: (toolType.interface_type as "web" | "terminal") || "web",
|
||||||
|
requires_port: toolType.requires_port ?? true,
|
||||||
|
default_port: toolType.default_port?.toString() || "",
|
||||||
|
definition_type:
|
||||||
|
(toolType.definition_type as "compose" | "dockerfile" | "manifest") ||
|
||||||
|
"compose",
|
||||||
|
compose_template: toolType.compose_template || "",
|
||||||
|
dockerfile_template: toolType.dockerfile_template || "",
|
||||||
|
readiness_command: toolType.readiness_probe?.command || "",
|
||||||
|
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
|
||||||
|
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
|
||||||
|
required_variables: toolType.required_variables?.join(", ") || "",
|
||||||
|
startup_command: toolType.startup_command || "",
|
||||||
|
});
|
||||||
|
setToolTypeError(null);
|
||||||
|
setToolTypeDirty(false);
|
||||||
|
setManifestDefinitionId(toolType.manifest_id || null);
|
||||||
|
|
||||||
|
if (toolType.definition_type === "manifest" && toolType.manifest_id) {
|
||||||
|
try {
|
||||||
|
const defn = await getToolDefinition(toolType.manifest_id);
|
||||||
|
setManifestData(defn.manifest);
|
||||||
|
} catch {
|
||||||
|
setManifestData(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setManifestData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectToolType = (toolType: ToolType | null) => {
|
||||||
|
if (toolTypeDirty) {
|
||||||
|
if (!window.confirm("You have unsaved changes. Discard them?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toolType) {
|
||||||
|
setSelectedToolTypeId(toolType.id);
|
||||||
|
setIsCreating(false);
|
||||||
|
void populateToolTypeForm(toolType);
|
||||||
|
} else {
|
||||||
|
setSelectedToolTypeId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
if (toolTypeDirty) {
|
||||||
|
if (!window.confirm("You have unsaved changes. Discard them?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedToolTypeId(null);
|
||||||
|
setIsCreating(true);
|
||||||
|
resetToolTypeForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToolTypeSubmit = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
setToolTypeError(null);
|
||||||
|
|
||||||
|
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
|
||||||
|
setToolTypeError("Name and display name are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
toolTypeForm.requires_port &&
|
||||||
|
(!toolTypeForm.default_port.trim() ||
|
||||||
|
isNaN(Number(toolTypeForm.default_port)))
|
||||||
|
) {
|
||||||
|
setToolTypeError("Default port is required and must be a number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolTypeForm.definition_type !== "manifest") {
|
||||||
|
const template =
|
||||||
|
toolTypeForm.definition_type === "compose"
|
||||||
|
? toolTypeForm.compose_template
|
||||||
|
: toolTypeForm.dockerfile_template;
|
||||||
|
|
||||||
|
if (!template.trim()) {
|
||||||
|
setToolTypeError(
|
||||||
|
`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (!manifestData) {
|
||||||
|
setToolTypeError(
|
||||||
|
"Manifest data is required for manifest definition type",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = toolTypeForm.required_variables
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v.length > 0);
|
||||||
|
|
||||||
|
const readinessProbe: ReadinessProbe | undefined =
|
||||||
|
toolTypeForm.readiness_command.trim()
|
||||||
|
? {
|
||||||
|
command: toolTypeForm.readiness_command.trim(),
|
||||||
|
timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
|
||||||
|
interval: parseInt(toolTypeForm.readiness_interval) || 2,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const template =
|
||||||
|
toolTypeForm.definition_type === "compose"
|
||||||
|
? toolTypeForm.compose_template
|
||||||
|
: toolTypeForm.dockerfile_template;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isCreating) {
|
||||||
|
let manifestId: string | undefined;
|
||||||
|
if (toolTypeForm.definition_type === "manifest" && manifestData) {
|
||||||
|
const manifestPayload = {
|
||||||
|
name: toolTypeForm.name.trim(),
|
||||||
|
display_name: toolTypeForm.display_name.trim(),
|
||||||
|
description: toolTypeForm.description.trim() || undefined,
|
||||||
|
category: toolTypeForm.category.trim() || undefined,
|
||||||
|
interface_type: toolTypeForm.interface_type,
|
||||||
|
base_image: (manifestData.base_image as string) || undefined,
|
||||||
|
base_definition_id:
|
||||||
|
(manifestData.base_definition_id as string) || undefined,
|
||||||
|
manifest: manifestData,
|
||||||
|
};
|
||||||
|
const newManifest = await createToolDefinition(manifestPayload);
|
||||||
|
manifestId = newManifest.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: CreateToolTypeRequest = {
|
||||||
|
name: toolTypeForm.name.trim(),
|
||||||
|
display_name: toolTypeForm.display_name.trim(),
|
||||||
|
description: toolTypeForm.description.trim() || undefined,
|
||||||
|
category: toolTypeForm.category.trim() || undefined,
|
||||||
|
interface_type: toolTypeForm.interface_type,
|
||||||
|
requires_port: toolTypeForm.requires_port,
|
||||||
|
default_port: toolTypeForm.requires_port
|
||||||
|
? Number(toolTypeForm.default_port)
|
||||||
|
: 0,
|
||||||
|
definition_type: toolTypeForm.definition_type,
|
||||||
|
manifest_id: manifestId,
|
||||||
|
compose_template:
|
||||||
|
toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||||
|
dockerfile_template:
|
||||||
|
toolTypeForm.definition_type === "dockerfile"
|
||||||
|
? template
|
||||||
|
: undefined,
|
||||||
|
readiness_probe: readinessProbe,
|
||||||
|
required_variables: variables,
|
||||||
|
startup_command: toolTypeForm.startup_command.trim() || undefined,
|
||||||
|
};
|
||||||
|
const newTool = await createToolType(input);
|
||||||
|
setIsCreating(false);
|
||||||
|
setSelectedToolTypeId(newTool.id);
|
||||||
|
setToolTypeDirty(false);
|
||||||
|
} else if (selectedToolType) {
|
||||||
|
let manifestId = selectedToolType.manifest_id || undefined;
|
||||||
|
if (toolTypeForm.definition_type === "manifest" && manifestData) {
|
||||||
|
if (manifestId) {
|
||||||
|
await updateToolDefinition(manifestId, {
|
||||||
|
display_name: toolTypeForm.display_name.trim(),
|
||||||
|
description: toolTypeForm.description.trim() || undefined,
|
||||||
|
category: toolTypeForm.category.trim() || undefined,
|
||||||
|
manifest: manifestData,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const manifestPayload = {
|
||||||
|
name: toolTypeForm.name.trim(),
|
||||||
|
display_name: toolTypeForm.display_name.trim(),
|
||||||
|
description: toolTypeForm.description.trim() || undefined,
|
||||||
|
category: toolTypeForm.category.trim() || undefined,
|
||||||
|
interface_type: toolTypeForm.interface_type,
|
||||||
|
base_image: (manifestData.base_image as string) || undefined,
|
||||||
|
base_definition_id:
|
||||||
|
(manifestData.base_definition_id as string) || undefined,
|
||||||
|
manifest: manifestData,
|
||||||
|
};
|
||||||
|
const newManifest = await createToolDefinition(manifestPayload);
|
||||||
|
manifestId = newManifest.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: UpdateToolTypeRequest = {
|
||||||
|
display_name: toolTypeForm.display_name.trim(),
|
||||||
|
description: toolTypeForm.description.trim() || undefined,
|
||||||
|
category: toolTypeForm.category.trim() || undefined,
|
||||||
|
interface_type: toolTypeForm.interface_type,
|
||||||
|
requires_port: toolTypeForm.requires_port,
|
||||||
|
default_port: toolTypeForm.requires_port
|
||||||
|
? Number(toolTypeForm.default_port)
|
||||||
|
: 0,
|
||||||
|
definition_type: toolTypeForm.definition_type,
|
||||||
|
manifest_id:
|
||||||
|
toolTypeForm.definition_type === "manifest"
|
||||||
|
? manifestId
|
||||||
|
: undefined,
|
||||||
|
compose_template:
|
||||||
|
toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||||
|
dockerfile_template:
|
||||||
|
toolTypeForm.definition_type === "dockerfile"
|
||||||
|
? template
|
||||||
|
: undefined,
|
||||||
|
readiness_probe: readinessProbe,
|
||||||
|
required_variables: variables,
|
||||||
|
startup_command: toolTypeForm.startup_command.trim() || undefined,
|
||||||
|
};
|
||||||
|
await updateToolType(selectedToolType.id, input);
|
||||||
|
setToolTypeDirty(false);
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
setToolTypeError(extractErrorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteToolType = async (id: string) => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Delete this tool type? All associated configs will be removed.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await deleteToolType(id);
|
||||||
|
if (selectedToolTypeId === id) {
|
||||||
|
setSelectedToolTypeId(null);
|
||||||
|
setIsCreating(false);
|
||||||
|
resetToolTypeForm();
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
alert("Failed to delete tool type");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormChange = (changes: Partial<ToolTypeFormState>) => {
|
||||||
|
setToolTypeForm((prev) => ({ ...prev, ...changes }));
|
||||||
|
setToolTypeDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (isCreating) {
|
||||||
|
resetToolTypeForm();
|
||||||
|
} else if (selectedToolType) {
|
||||||
|
void populateToolTypeForm(selectedToolType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
toolTypes,
|
||||||
|
baseDefinitions,
|
||||||
|
selectedToolType,
|
||||||
|
selectedToolTypeId,
|
||||||
|
isCreating,
|
||||||
|
toolTypeForm,
|
||||||
|
manifestData,
|
||||||
|
manifestDefinitionId,
|
||||||
|
toolTypeError,
|
||||||
|
toolTypeDirty,
|
||||||
|
loadData,
|
||||||
|
handleSelectToolType,
|
||||||
|
handleCreateNew,
|
||||||
|
handleToolTypeSubmit,
|
||||||
|
handleDeleteToolType,
|
||||||
|
handleFormChange,
|
||||||
|
handleReset,
|
||||||
|
setManifestData,
|
||||||
|
setToolTypeDirty,
|
||||||
|
};
|
||||||
|
};
|
||||||
+19
-10
@@ -5,16 +5,25 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
import { AppRouter } from "./router";
|
import { AppRouter } from "./router";
|
||||||
import { AuthProvider } from "./state/auth";
|
import { AuthProvider } from "./state/auth";
|
||||||
import { SessionsProvider } from "./state/sessions";
|
import { SessionsProvider } from "./state/sessions";
|
||||||
import "./styles.css";
|
import "./styles/tokens.css";
|
||||||
|
import "./styles/global.css";
|
||||||
|
import "./styles/utilities.css";
|
||||||
|
import "./styles/syntax-highlight.css";
|
||||||
|
import "./styles/pages/git-history.css";
|
||||||
|
import "./styles/pages/repo-workspace.css";
|
||||||
|
import "./styles/pages/projects.css";
|
||||||
|
import "./styles/pages/sessions.css";
|
||||||
|
import "./styles/pages/ssh-keys.css";
|
||||||
|
import "./styles/pages/workspace-detail.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SessionsProvider>
|
<SessionsProvider>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</SessionsProvider>
|
</SessionsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +1,37 @@
|
|||||||
/** Projects page with inline repositories and workspaces. */
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createProject,
|
|
||||||
deleteProject,
|
|
||||||
listProjects,
|
|
||||||
updateProject,
|
|
||||||
type ProjectCreateInput,
|
|
||||||
type ProjectUpdateInput,
|
|
||||||
} from "../api/projects";
|
|
||||||
import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
|
|
||||||
import {
|
|
||||||
EmptyState,
|
|
||||||
ErrorState,
|
|
||||||
LoadingState,
|
|
||||||
} from "../components/data-states";
|
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
|
import { ProjectCard } from "../components/features/project/ProjectCard";
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { ProjectDialog } from "../components/features/project/ProjectDialog";
|
||||||
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
import { useProjects } from "../hooks/use-projects";
|
||||||
|
|
||||||
type DialogMode = "none" | "create" | "edit";
|
|
||||||
|
|
||||||
export const ProjectsPage = () => {
|
export const ProjectsPage = () => {
|
||||||
const {
|
const {
|
||||||
data: projects,
|
projects,
|
||||||
status,
|
status,
|
||||||
reload,
|
reload,
|
||||||
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
|
dialogMode,
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
formName,
|
||||||
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
|
setFormName,
|
||||||
null,
|
formDescription,
|
||||||
);
|
setFormDescription,
|
||||||
const [formName, setFormName] = useState("");
|
formError,
|
||||||
const [formDescription, setFormDescription] = useState("");
|
deleteConfirmId,
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
setDeleteConfirmId,
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
expandedProject,
|
||||||
const [expandedProject, setExpandedProject] = useState<string | null>(null);
|
setExpandedProject,
|
||||||
const [creatingWorkspace, setCreatingWorkspace] = useState<{
|
creatingWorkspace,
|
||||||
projectId: string;
|
setCreatingWorkspace,
|
||||||
repoId: string;
|
workspaceLoading,
|
||||||
} | null>(null);
|
openCreate,
|
||||||
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
|
openEdit,
|
||||||
|
closeDialog,
|
||||||
|
handleSubmit,
|
||||||
|
handleDelete,
|
||||||
|
handleSyncWorkspace,
|
||||||
|
handleDeleteWorkspace,
|
||||||
|
} = useProjects();
|
||||||
|
|
||||||
const safeProjects = projects ?? [];
|
const isEmpty = status === "ready" && projects.length === 0;
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setFormName("");
|
|
||||||
setFormDescription("");
|
|
||||||
setFormError(null);
|
|
||||||
setEditingProject(null);
|
|
||||||
setDialogMode("create");
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (project: ProjectWithRepos) => {
|
|
||||||
setFormName(project.name);
|
|
||||||
setFormDescription(project.description ?? "");
|
|
||||||
setFormError(null);
|
|
||||||
setEditingProject(project);
|
|
||||||
setDialogMode("edit");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setDialogMode("none");
|
|
||||||
setEditingProject(null);
|
|
||||||
setFormError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
if (!formName.trim()) {
|
|
||||||
setFormError("Project name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (dialogMode === "create") {
|
|
||||||
const input: ProjectCreateInput = {
|
|
||||||
name: formName.trim(),
|
|
||||||
description: formDescription.trim() || null,
|
|
||||||
};
|
|
||||||
await createProject(input);
|
|
||||||
} else if (dialogMode === "edit" && editingProject) {
|
|
||||||
const input: ProjectUpdateInput = {
|
|
||||||
name: formName.trim(),
|
|
||||||
description: formDescription.trim() || null,
|
|
||||||
};
|
|
||||||
await updateProject(editingProject.id, input);
|
|
||||||
}
|
|
||||||
closeDialog();
|
|
||||||
reload();
|
|
||||||
} catch {
|
|
||||||
setFormError("Failed to save project");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (projectId: string) => {
|
|
||||||
try {
|
|
||||||
await deleteProject(projectId);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
reload();
|
|
||||||
} catch {
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSyncWorkspace = async (
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
workspace: WorkspaceSummary,
|
|
||||||
) => {
|
|
||||||
setWorkspaceLoading(workspace.id);
|
|
||||||
try {
|
|
||||||
await syncWorkspace(projectId, repoId, workspace.id);
|
|
||||||
reload();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : "Failed to sync workspace");
|
|
||||||
} finally {
|
|
||||||
setWorkspaceLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteWorkspace = async (workspace: WorkspaceSummary) => {
|
|
||||||
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
|
|
||||||
setWorkspaceLoading(workspace.id);
|
|
||||||
try {
|
|
||||||
await deleteWorkspace(workspace.id);
|
|
||||||
reload();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : "Failed to delete workspace");
|
|
||||||
} finally {
|
|
||||||
setWorkspaceLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
@@ -159,13 +53,20 @@ export const ProjectsPage = () => {
|
|||||||
<EmptyState message="No projects yet. Create your first project above." />
|
<EmptyState message="No projects yet. Create your first project above." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "ready" && safeProjects.length > 0 && (
|
{status === "ready" && projects.length > 0 && (
|
||||||
<div className="project-list">
|
<div className="project-list">
|
||||||
{safeProjects.map((project) => (
|
{projects.map((project) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
project={project}
|
project={project}
|
||||||
expanded={expandedProject === project.id}
|
expanded={expandedProject === project.id}
|
||||||
|
deleteConfirm={deleteConfirmId === project.id}
|
||||||
|
workspaceLoading={workspaceLoading}
|
||||||
|
showCreateForm={
|
||||||
|
creatingWorkspace?.projectId === project.id
|
||||||
|
? creatingWorkspace.repoId
|
||||||
|
: null
|
||||||
|
}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
setExpandedProject(
|
setExpandedProject(
|
||||||
expandedProject === project.id ? null : project.id,
|
expandedProject === project.id ? null : project.id,
|
||||||
@@ -173,7 +74,6 @@ export const ProjectsPage = () => {
|
|||||||
}
|
}
|
||||||
onEdit={() => openEdit(project)}
|
onEdit={() => openEdit(project)}
|
||||||
onDelete={() => setDeleteConfirmId(project.id)}
|
onDelete={() => setDeleteConfirmId(project.id)}
|
||||||
deleteConfirm={deleteConfirmId === project.id}
|
|
||||||
onConfirmDelete={() => void handleDelete(project.id)}
|
onConfirmDelete={() => void handleDelete(project.id)}
|
||||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||||
onCreateWorkspace={(repoId) =>
|
onCreateWorkspace={(repoId) =>
|
||||||
@@ -186,12 +86,6 @@ export const ProjectsPage = () => {
|
|||||||
void handleDeleteWorkspace(workspace);
|
void handleDeleteWorkspace(workspace);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
workspaceLoading={workspaceLoading}
|
|
||||||
showCreateForm={
|
|
||||||
creatingWorkspace?.projectId === project.id
|
|
||||||
? creatingWorkspace.repoId
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onCancelCreate={() => setCreatingWorkspace(null)}
|
onCancelCreate={() => setCreatingWorkspace(null)}
|
||||||
onCreated={() => {
|
onCreated={() => {
|
||||||
setCreatingWorkspace(null);
|
setCreatingWorkspace(null);
|
||||||
@@ -203,231 +97,17 @@ export const ProjectsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{dialogMode !== "none" && (
|
{dialogMode !== "none" && (
|
||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<ProjectDialog
|
||||||
<div className="dialog">
|
mode={dialogMode}
|
||||||
<h2>
|
name={formName}
|
||||||
{dialogMode === "create" ? "Create Project" : "Edit Project"}
|
description={formDescription}
|
||||||
</h2>
|
error={formError}
|
||||||
<form onSubmit={handleSubmit} className="stack">
|
onNameChange={setFormName}
|
||||||
<label className="form-field">
|
onDescriptionChange={setFormDescription}
|
||||||
Name
|
onSubmit={handleSubmit}
|
||||||
<input
|
onCancel={closeDialog}
|
||||||
type="text"
|
/>
|
||||||
value={formName}
|
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
|
||||||
placeholder="Project name"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Description
|
|
||||||
<textarea
|
|
||||||
value={formDescription}
|
|
||||||
onChange={(e) => setFormDescription(e.target.value)}
|
|
||||||
placeholder="Optional description"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{formError && <p className="error-text">{formError}</p>}
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={closeDialog}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="primary-button" type="submit">
|
|
||||||
{dialogMode === "create" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Create
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="save" size="sm" />
|
|
||||||
Save
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ─── Project Card ─── */
|
|
||||||
|
|
||||||
function ProjectCard({
|
|
||||||
project,
|
|
||||||
expanded,
|
|
||||||
onToggle,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
deleteConfirm,
|
|
||||||
onConfirmDelete,
|
|
||||||
onCancelDelete,
|
|
||||||
onCreateWorkspace,
|
|
||||||
onWorkspaceAction,
|
|
||||||
workspaceLoading,
|
|
||||||
showCreateForm,
|
|
||||||
onCancelCreate,
|
|
||||||
onCreated,
|
|
||||||
}: {
|
|
||||||
project: ProjectWithRepos;
|
|
||||||
expanded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
deleteConfirm: boolean;
|
|
||||||
onConfirmDelete: () => void;
|
|
||||||
onCancelDelete: () => void;
|
|
||||||
onCreateWorkspace: (repoId: string) => void;
|
|
||||||
onWorkspaceAction: (
|
|
||||||
repoId: string,
|
|
||||||
workspace: WorkspaceSummary,
|
|
||||||
action: "sync" | "delete",
|
|
||||||
) => void;
|
|
||||||
workspaceLoading: string | null;
|
|
||||||
onCancelCreate: () => void;
|
|
||||||
showCreateForm: string | null;
|
|
||||||
onCreated: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<article className="card project-card">
|
|
||||||
<div className="project-info-row">
|
|
||||||
<button
|
|
||||||
className="project-toggle"
|
|
||||||
onClick={onToggle}
|
|
||||||
type="button"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
>
|
|
||||||
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
|
|
||||||
<h3>{project.name}</h3>
|
|
||||||
{project.repositories.length > 0 && (
|
|
||||||
<span className="repo-count">
|
|
||||||
{project.repositories.length} repo
|
|
||||||
{project.repositories.length > 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div className="project-actions">
|
|
||||||
<button className="ghost-button" onClick={onEdit} type="button">
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
{deleteConfirm ? (
|
|
||||||
<div className="delete-confirm">
|
|
||||||
<span>Are you sure?</span>
|
|
||||||
<button
|
|
||||||
className="danger-button"
|
|
||||||
onClick={onConfirmDelete}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={onCancelDelete}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button danger-text"
|
|
||||||
onClick={onDelete}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="project-detail">
|
|
||||||
{project.repositories.length === 0 ? (
|
|
||||||
<p className="muted">No repositories yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="repo-list">
|
|
||||||
{project.repositories.map((repo) => (
|
|
||||||
<div key={repo.id} className="repo-block">
|
|
||||||
<div className="repo-header">
|
|
||||||
<h4>{repo.name}</h4>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-primary"
|
|
||||||
onClick={() => onCreateWorkspace(repo.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="add" size="sm" /> New Workspace
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showCreateForm === repo.id && (
|
|
||||||
<WorkspaceCreateForm
|
|
||||||
defaultProjectId={project.id}
|
|
||||||
defaultRepoId={repo.id}
|
|
||||||
onSubmit={onCreated}
|
|
||||||
onCancel={onCancelCreate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{repo.workspaces.length === 0 ? (
|
|
||||||
<p className="muted">No workspaces.</p>
|
|
||||||
) : (
|
|
||||||
<div className="workspace-grid">
|
|
||||||
{repo.workspaces.map((ws) => (
|
|
||||||
<div
|
|
||||||
key={ws.id}
|
|
||||||
className={`workspace-chip ${ws.status}`}
|
|
||||||
>
|
|
||||||
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
|
|
||||||
<span className="ws-branch">
|
|
||||||
<Icon name="branch" size="sm" /> {ws.branch}
|
|
||||||
</span>
|
|
||||||
{ws.instance_count > 0 && (
|
|
||||||
<span className="ws-instances">
|
|
||||||
{ws.instance_count} tool
|
|
||||||
{ws.instance_count > 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="ws-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={workspaceLoading === ws.id}
|
|
||||||
onClick={() =>
|
|
||||||
onWorkspaceAction(repo.id, ws, "sync")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="danger-text"
|
|
||||||
disabled={workspaceLoading === ws.id}
|
|
||||||
onClick={() =>
|
|
||||||
onWorkspaceAction(repo.id, ws, "delete")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,505 +1,89 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
import { useRepoWorkspace } from "../hooks/use-repo-workspace";
|
||||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
|
||||||
|
|
||||||
import { apiClient } from "../api/client";
|
|
||||||
import {
|
|
||||||
getRepositoryStatus,
|
|
||||||
listRepositories,
|
|
||||||
type GitRepository,
|
|
||||||
type GitStatus,
|
|
||||||
} from "../api/git-repositories";
|
|
||||||
import { CommitPanel } from "../components/features/git/commit-panel";
|
|
||||||
import { FileEditor } from "../components/features/git/file-editor";
|
|
||||||
import { GitToolbar } from "../components/features/git/git-toolbar";
|
|
||||||
import { InstanceList } from "../components/features/tool/instance-list";
|
|
||||||
import { WorkspaceHeader } from "../components/features/workspace/workspace-header";
|
import { WorkspaceHeader } from "../components/features/workspace/workspace-header";
|
||||||
import { listToolTypes } from "../api/tool-types";
|
import { WorkspaceLayout } from "../components/features/workspace/WorkspaceLayout";
|
||||||
import type { ToolType } from "../api/tool-types";
|
|
||||||
|
|
||||||
type MobileTab = "files" | "editor" | "git" | "terminal";
|
type MobileTab = "files" | "editor" | "git" | "terminal";
|
||||||
|
|
||||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
|
||||||
|
|
||||||
interface FileTreeEntry {
|
|
||||||
name: string;
|
|
||||||
type: "file" | "directory";
|
|
||||||
path: string;
|
|
||||||
size?: number;
|
|
||||||
mode?: string;
|
|
||||||
last_commit?: {
|
|
||||||
hash: string;
|
|
||||||
message: string;
|
|
||||||
author: string;
|
|
||||||
date: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RepoWorkspace = () => {
|
export const RepoWorkspace = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
projectId,
|
||||||
const isMobile = useMobileViewport();
|
project,
|
||||||
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
status,
|
||||||
|
repositories,
|
||||||
|
selectedRepoId,
|
||||||
|
selectedRepo,
|
||||||
|
branches,
|
||||||
|
currentBranch,
|
||||||
|
gitStatus,
|
||||||
|
toolTypes,
|
||||||
|
handleRepoChange,
|
||||||
|
handleBranchChange,
|
||||||
|
loadGitStatus,
|
||||||
|
loadBranches,
|
||||||
|
loadRepositories,
|
||||||
|
} = useRepoWorkspace();
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
||||||
|
|
||||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
return (
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
<section className="repo-workspace">
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
{project && (
|
||||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
|
<WorkspaceHeader
|
||||||
searchParams.get("repo")
|
project={project}
|
||||||
);
|
currentRepo={selectedRepo || null}
|
||||||
const [branches, setBranches] = useState<string[]>([]);
|
/>
|
||||||
const [currentBranch, setCurrentBranch] = useState<string>("main");
|
)}
|
||||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
|
||||||
|
|
||||||
const loadProject = useCallback(async () => {
|
{status === "loading" && (
|
||||||
if (!projectId) return;
|
<LoadingState message="Loading repositories..." />
|
||||||
try {
|
)}
|
||||||
const response = await apiClient.get(`/projects/${projectId}`);
|
|
||||||
setProject(response.data);
|
|
||||||
} catch {
|
|
||||||
setProject(null);
|
|
||||||
}
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const loadRepositories = useCallback(async () => {
|
{status === "error" && (
|
||||||
if (!projectId) return;
|
<ErrorState
|
||||||
|
message="Failed to load repositories"
|
||||||
|
onRetry={() => void loadRepositories()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
setStatus("loading");
|
{status === "empty" && (
|
||||||
try {
|
<div className="card stack">
|
||||||
const data = await listRepositories(projectId);
|
<EmptyState message="No repositories in this project yet." />
|
||||||
setRepositories(data);
|
<Link
|
||||||
|
className="primary-button"
|
||||||
|
to={`/projects/${projectId}/settings/repositories`}
|
||||||
|
>
|
||||||
|
Manage Repositories
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
if (data.length === 0) {
|
{status === "ready" && repositories.length > 0 && (
|
||||||
setStatus("empty");
|
<WorkspaceLayout
|
||||||
} else {
|
projectId={projectId!}
|
||||||
setStatus("ready");
|
project={project}
|
||||||
// If no repo selected, select the first one
|
isMobile={isMobile}
|
||||||
if (!selectedRepoId) {
|
mobileTab={mobileTab}
|
||||||
setSelectedRepoId(data[0].id);
|
selectedRepoId={selectedRepoId}
|
||||||
const newParams = new URLSearchParams(searchParams);
|
selectedRepo={selectedRepo}
|
||||||
newParams.set("repo", data[0].id);
|
branches={branches}
|
||||||
setSearchParams(newParams, { replace: true });
|
currentBranch={currentBranch}
|
||||||
}
|
gitStatus={gitStatus}
|
||||||
}
|
toolTypes={toolTypes}
|
||||||
} catch {
|
repositories={repositories}
|
||||||
setRepositories([]);
|
onMobileTabChange={setMobileTab}
|
||||||
setStatus("error");
|
onRepoChange={handleRepoChange}
|
||||||
}
|
onBranchChange={handleBranchChange}
|
||||||
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
|
onRefresh={() => {
|
||||||
|
void loadBranches();
|
||||||
const loadBranches = useCallback(async () => {
|
void loadGitStatus();
|
||||||
if (!projectId || !selectedRepoId) return;
|
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||||
try {
|
}}
|
||||||
const response = await apiClient.get(
|
/>
|
||||||
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
|
)}
|
||||||
);
|
</section>
|
||||||
const branchList = response.data.branches.map((b: { name: string }) => b.name);
|
);
|
||||||
setBranches(branchList);
|
|
||||||
const defaultBranch = response.data.default_branch;
|
|
||||||
if (defaultBranch) {
|
|
||||||
setCurrentBranch(defaultBranch);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setBranches([]);
|
|
||||||
}
|
|
||||||
}, [projectId, selectedRepoId]);
|
|
||||||
|
|
||||||
const loadGitStatus = useCallback(async () => {
|
|
||||||
if (!projectId || !selectedRepoId) return;
|
|
||||||
try {
|
|
||||||
const data = await getRepositoryStatus(projectId, selectedRepoId);
|
|
||||||
setGitStatus(data);
|
|
||||||
} catch {
|
|
||||||
setGitStatus(null);
|
|
||||||
}
|
|
||||||
}, [projectId, selectedRepoId]);
|
|
||||||
|
|
||||||
const loadToolTypes = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await listToolTypes();
|
|
||||||
setToolTypes(data);
|
|
||||||
} catch {
|
|
||||||
setToolTypes([]);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadProject();
|
|
||||||
void loadRepositories();
|
|
||||||
void loadToolTypes();
|
|
||||||
}, [loadProject, loadRepositories, loadToolTypes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadBranches();
|
|
||||||
void loadGitStatus();
|
|
||||||
}, [loadBranches, loadGitStatus]);
|
|
||||||
|
|
||||||
const handleRepoChange = (repoId: string) => {
|
|
||||||
setSelectedRepoId(repoId);
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
newParams.set("repo", repoId);
|
|
||||||
newParams.delete("branch");
|
|
||||||
newParams.delete("path");
|
|
||||||
setSearchParams(newParams);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="repo-workspace">
|
|
||||||
{project && (
|
|
||||||
<WorkspaceHeader
|
|
||||||
project={project}
|
|
||||||
currentRepo={selectedRepo || null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "loading" && (
|
|
||||||
<LoadingState message="Loading repositories..." />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "error" && (
|
|
||||||
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "empty" && (
|
|
||||||
<div className="card stack">
|
|
||||||
<EmptyState message="No repositories in this project yet." />
|
|
||||||
<Link
|
|
||||||
className="primary-button"
|
|
||||||
to={`/projects/${projectId}/settings/repositories`}
|
|
||||||
>
|
|
||||||
Manage Repositories
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "ready" && repositories.length > 0 && (
|
|
||||||
<>
|
|
||||||
{isMobile ? (
|
|
||||||
// Mobile Layout
|
|
||||||
<div className="mobile-workspace">
|
|
||||||
<div className="mobile-workspace-header">
|
|
||||||
<select
|
|
||||||
value={selectedRepoId || ""}
|
|
||||||
onChange={(e) => handleRepoChange(e.target.value)}
|
|
||||||
className="mobile-repo-selector"
|
|
||||||
>
|
|
||||||
{repositories.map((repo) => (
|
|
||||||
<option key={repo.id} value={repo.id}>
|
|
||||||
{repo.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{selectedRepoId && (
|
|
||||||
<select
|
|
||||||
value={currentBranch}
|
|
||||||
onChange={(e) => {
|
|
||||||
const branch = e.target.value;
|
|
||||||
setCurrentBranch(branch);
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
newParams.set("branch", branch);
|
|
||||||
setSearchParams(newParams);
|
|
||||||
}}
|
|
||||||
className="mobile-branch-selector"
|
|
||||||
>
|
|
||||||
{branches.map((branch) => (
|
|
||||||
<option key={branch} value={branch}>
|
|
||||||
{branch}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mobile-workspace-content">
|
|
||||||
{mobileTab === "files" && selectedRepoId && (
|
|
||||||
<FileBrowser
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
gitStatus={gitStatus}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{mobileTab === "editor" && selectedRepoId && (
|
|
||||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
|
||||||
)}
|
|
||||||
{mobileTab === "git" && selectedRepoId && gitStatus && (
|
|
||||||
<div className="mobile-git-view">
|
|
||||||
<CommitPanel
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
modified={gitStatus.modified}
|
|
||||||
added={gitStatus.added}
|
|
||||||
deleted={gitStatus.deleted}
|
|
||||||
untracked={gitStatus.untracked}
|
|
||||||
onCommit={() => {
|
|
||||||
void loadGitStatus();
|
|
||||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mobileTab === "terminal" && selectedRepoId && (
|
|
||||||
<InstanceList
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
projectName={project?.name}
|
|
||||||
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
|
||||||
toolTypes={toolTypes}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mobile-workspace-tabs">
|
|
||||||
<button
|
|
||||||
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
|
|
||||||
onClick={() => setMobileTab("files")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="folder" size="sm" />
|
|
||||||
<span>Files</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
|
|
||||||
onClick={() => setMobileTab("editor")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
<span>Editor</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
|
|
||||||
onClick={() => setMobileTab("git")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="branch" size="sm" />
|
|
||||||
<span>Git</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
|
|
||||||
onClick={() => setMobileTab("terminal")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="terminal" size="sm" />
|
|
||||||
<span>Terminal</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Desktop Layout
|
|
||||||
<>
|
|
||||||
{selectedRepoId && (
|
|
||||||
<GitToolbar
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
currentBranch={currentBranch}
|
|
||||||
branches={branches}
|
|
||||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
|
||||||
isMirror={Boolean(selectedRepo?.is_mirror)}
|
|
||||||
onBranchChange={(branch) => {
|
|
||||||
setCurrentBranch(branch);
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
newParams.set("branch", branch);
|
|
||||||
setSearchParams(newParams);
|
|
||||||
}}
|
|
||||||
onRefresh={() => {
|
|
||||||
void loadBranches();
|
|
||||||
void loadGitStatus();
|
|
||||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="workspace-layout">
|
|
||||||
<aside className="workspace-sidebar">
|
|
||||||
<div className="sidebar-section">
|
|
||||||
<label className="form-field">
|
|
||||||
Repository
|
|
||||||
<select
|
|
||||||
value={selectedRepoId || ""}
|
|
||||||
onChange={(e) => handleRepoChange(e.target.value)}
|
|
||||||
>
|
|
||||||
{repositories.map((repo) => (
|
|
||||||
<option key={repo.id} value={repo.id}>
|
|
||||||
{repo.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedRepoId && (
|
|
||||||
<>
|
|
||||||
<FileBrowser
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
gitStatus={gitStatus}
|
|
||||||
/>
|
|
||||||
{gitStatus && (
|
|
||||||
<CommitPanel
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
modified={gitStatus.modified}
|
|
||||||
added={gitStatus.added}
|
|
||||||
deleted={gitStatus.deleted}
|
|
||||||
untracked={gitStatus.untracked}
|
|
||||||
onCommit={() => {
|
|
||||||
void loadGitStatus();
|
|
||||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<InstanceList
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
projectName={project?.name}
|
|
||||||
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
|
||||||
toolTypes={toolTypes}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="workspace-main">
|
|
||||||
{selectedRepoId && (
|
|
||||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// File Browser Component
|
|
||||||
const FileBrowser = ({
|
|
||||||
projectId,
|
|
||||||
repoId,
|
|
||||||
gitStatus,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
repoId: string;
|
|
||||||
gitStatus: GitStatus | null;
|
|
||||||
}) => {
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const branch = searchParams.get("branch") || "main";
|
|
||||||
const path = searchParams.get("path") || "";
|
|
||||||
|
|
||||||
const loadFiles = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/files`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
branch,
|
|
||||||
path,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setEntries(response.data.entries || []);
|
|
||||||
} catch {
|
|
||||||
setError("Failed to load files");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [projectId, repoId, branch, path]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadFiles();
|
|
||||||
}, [loadFiles]);
|
|
||||||
|
|
||||||
// Listen for refresh events
|
|
||||||
useEffect(() => {
|
|
||||||
const handleRefresh = () => void loadFiles();
|
|
||||||
window.addEventListener("refresh-file-tree", handleRefresh);
|
|
||||||
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
|
||||||
}, [loadFiles]);
|
|
||||||
|
|
||||||
const handleEntryClick = (entry: FileTreeEntry) => {
|
|
||||||
if (entry.type === "directory") {
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
newParams.set("path", entry.path);
|
|
||||||
setSearchParams(newParams);
|
|
||||||
} else {
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
newParams.set("file", entry.path);
|
|
||||||
setSearchParams(newParams);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigateUp = () => {
|
|
||||||
if (!path) return;
|
|
||||||
const parentPath = path.split("/").slice(0, -1).join("/");
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
if (parentPath) {
|
|
||||||
newParams.set("path", parentPath);
|
|
||||||
} else {
|
|
||||||
newParams.delete("path");
|
|
||||||
}
|
|
||||||
setSearchParams(newParams);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFileStatus = (filePath: string): string | null => {
|
|
||||||
if (!gitStatus) return null;
|
|
||||||
if (gitStatus.modified.includes(filePath)) return "modified";
|
|
||||||
if (gitStatus.added.includes(filePath)) return "added";
|
|
||||||
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
|
||||||
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <p className="muted">Loading files...</p>;
|
|
||||||
if (error) return <p className="error-text">{error}</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="file-tree">
|
|
||||||
{path && (
|
|
||||||
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
|
|
||||||
<Icon name="folder" size="sm" /> ..
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{entries.length === 0 && (
|
|
||||||
<EmptyState message="No files in this repository yet." />
|
|
||||||
)}
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={entry.path}
|
|
||||||
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
|
||||||
onClick={() => handleEntryClick(entry)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
|
|
||||||
{fileStatus && (
|
|
||||||
<span className={`file-status-indicator ${fileStatus}`}>
|
|
||||||
{fileStatus === "modified" && "M"}
|
|
||||||
{fileStatus === "added" && "A"}
|
|
||||||
{fileStatus === "deleted" && "D"}
|
|
||||||
{fileStatus === "untracked" && "?"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getUserConfig,
|
getUserConfig,
|
||||||
updateUserConfig,
|
updateUserConfig,
|
||||||
@@ -8,7 +7,6 @@ import {
|
|||||||
type UserConfigUpdate,
|
type UserConfigUpdate,
|
||||||
} from "../api/settings";
|
} from "../api/settings";
|
||||||
import { ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -16,29 +14,7 @@ const TABS = [
|
|||||||
{ label: "SSH Keys", path: "ssh-keys" },
|
{ label: "SSH Keys", path: "ssh-keys" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
export { GeneralSettingsTab } from "../components/features/settings/GeneralSettingsTab";
|
||||||
{ value: "system", label: "System" },
|
|
||||||
{ value: "light", label: "Light" },
|
|
||||||
{ value: "dark", label: "Dark" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TOAST_LEVEL_OPTIONS = [
|
|
||||||
{ value: "all", label: "All" },
|
|
||||||
{ value: "errors", label: "Errors only" },
|
|
||||||
{ value: "none", label: "None" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MUTE_CATEGORIES = ["instance", "system", "health", "security"];
|
|
||||||
|
|
||||||
type SettingsOutletContext = {
|
|
||||||
config: UserConfig;
|
|
||||||
handleChange: (
|
|
||||||
key: keyof UserConfigUpdate,
|
|
||||||
value: string | string[] | null,
|
|
||||||
) => void;
|
|
||||||
handleSave: () => Promise<void>;
|
|
||||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -60,7 +36,6 @@ export const SettingsPage = () => {
|
|||||||
"idle" | "saving" | "saved" | "error"
|
"idle" | "saving" | "saved" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
|
|
||||||
// Sync loaded config into local editable state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadedConfig) {
|
if (loadedConfig) {
|
||||||
setConfig({
|
setConfig({
|
||||||
@@ -160,125 +135,3 @@ export const SettingsPage = () => {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GeneralSettingsTab = () => {
|
|
||||||
const { config, handleChange, handleSave, saveStatus } =
|
|
||||||
useOutletContext<SettingsOutletContext>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="stack">
|
|
||||||
<h2>General</h2>
|
|
||||||
<label className="form-field">
|
|
||||||
Theme
|
|
||||||
<select
|
|
||||||
value={config.theme}
|
|
||||||
onChange={(e) => handleChange("theme", e.target.value)}
|
|
||||||
>
|
|
||||||
{THEME_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Git user name
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.git_user_name ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange("git_user_name", e.target.value || null)
|
|
||||||
}
|
|
||||||
placeholder="Your git commit name"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Git user email
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={config.git_user_email ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange("git_user_email", e.target.value || null)
|
|
||||||
}
|
|
||||||
placeholder="your.email@example.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Default editor
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.default_editor ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange("default_editor", e.target.value || null)
|
|
||||||
}
|
|
||||||
placeholder="e.g., vscode, vim, cursor"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<label className="form-field">
|
|
||||||
Toast level
|
|
||||||
<select
|
|
||||||
value={config.notification_toast_level ?? "all"}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange("notification_toast_level", e.target.value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{TOAST_LEVEL_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<fieldset className="form-field">
|
|
||||||
<legend>Mute categories</legend>
|
|
||||||
<div className="stack-sm">
|
|
||||||
{MUTE_CATEGORIES.map((cat) => (
|
|
||||||
<label
|
|
||||||
key={cat}
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(config.notification_mute_categories ?? []).includes(
|
|
||||||
cat,
|
|
||||||
)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const current = config.notification_mute_categories ?? [];
|
|
||||||
const next = e.target.checked
|
|
||||||
? [...current, cat]
|
|
||||||
: current.filter((c) => c !== cat);
|
|
||||||
handleChange("notification_mute_categories", next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{cat}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<div className="settings-actions">
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
onClick={() => void handleSave()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{saveStatus === "saving" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" /> Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="save" size="sm" /> Save Settings
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{saveStatus === "saved" && (
|
|
||||||
<span className="success-text">Settings saved!</span>
|
|
||||||
)}
|
|
||||||
{saveStatus === "error" && (
|
|
||||||
<span className="error-text">Failed to save</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,92 +1,35 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh-keys";
|
import { LoadingState } from "../components/data-states";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { useSSHKeys } from "../hooks/use-ssh-keys";
|
||||||
import { Icon } from "../components/icon";
|
import { SSHKeyCreateForm } from "../components/features/ssh-keys/SSHKeyCreateForm";
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { SSHKeyList } from "../components/features/ssh-keys/SSHKeyList";
|
||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
const {
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
keys,
|
||||||
const [generating, setGenerating] = useState(false);
|
status,
|
||||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
loadKeys,
|
||||||
const [signatures, setSignatures] = useState<Record<string, string>>({});
|
newKeyName,
|
||||||
const [signing, setSigning] = useState<Record<string, boolean>>({});
|
setNewKeyName,
|
||||||
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
|
generating,
|
||||||
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
mutationError,
|
||||||
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
signPayloads,
|
||||||
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
signatures,
|
||||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
signing,
|
||||||
|
verifyPayloads,
|
||||||
const safeKeys = keys ?? [];
|
verifySignatures,
|
||||||
|
verifyResults,
|
||||||
async function handleGenerate(e: React.FormEvent) {
|
verifying,
|
||||||
e.preventDefault();
|
handleGenerate,
|
||||||
if (!newKeyName.trim()) return;
|
handleDelete,
|
||||||
|
copyToClipboard,
|
||||||
try {
|
handleSign,
|
||||||
setGenerating(true);
|
handleVerify,
|
||||||
await createSSHKey({ name: newKeyName.trim() });
|
setSignPayloads,
|
||||||
setNewKeyName("");
|
setVerifyPayloads,
|
||||||
await loadKeys();
|
setVerifySignatures,
|
||||||
} catch {
|
} = useSSHKeys();
|
||||||
setMutationError("Failed to generate SSH key");
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(keyId: string) {
|
|
||||||
if (!confirm("Are you sure you want to delete this SSH key?")) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteSSHKey(keyId);
|
|
||||||
await loadKeys();
|
|
||||||
} catch {
|
|
||||||
setMutationError("Failed to delete SSH key");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToClipboard(text: string) {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSign(keyId: string) {
|
|
||||||
const payload = signPayloads[keyId];
|
|
||||||
if (!payload?.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
|
||||||
const result = await signPayload(keyId, { payload: payload.trim() });
|
|
||||||
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
|
||||||
setMutationError(null);
|
|
||||||
} catch {
|
|
||||||
setMutationError("Failed to sign payload");
|
|
||||||
} finally {
|
|
||||||
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleVerify(keyId: string) {
|
|
||||||
const payload = verifyPayloads[keyId];
|
|
||||||
const signature = verifySignatures[keyId];
|
|
||||||
if (!payload?.trim() || !signature?.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setVerifying((prev) => ({ ...prev, [keyId]: true }));
|
|
||||||
const result = await verifySignature(keyId, {
|
|
||||||
payload: payload.trim(),
|
|
||||||
signature: signature.trim(),
|
|
||||||
});
|
|
||||||
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
|
||||||
setMutationError(null);
|
|
||||||
} catch {
|
|
||||||
setMutationError("Failed to verify signature");
|
|
||||||
} finally {
|
|
||||||
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
||||||
|
|
||||||
@@ -104,174 +47,38 @@ export const SSHKeysPage = () => {
|
|||||||
|
|
||||||
{mutationError && <div className="error">{mutationError}</div>}
|
{mutationError && <div className="error">{mutationError}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleGenerate} className="stack">
|
<SSHKeyCreateForm
|
||||||
<div className="form-group">
|
newKeyName={newKeyName}
|
||||||
<label htmlFor="key-name">Key Name</label>
|
setNewKeyName={setNewKeyName}
|
||||||
<input
|
generating={generating}
|
||||||
id="key-name"
|
onSubmit={handleGenerate}
|
||||||
type="text"
|
/>
|
||||||
value={newKeyName}
|
|
||||||
onChange={(e) => setNewKeyName(e.target.value)}
|
|
||||||
placeholder="e.g., GitHub Work"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="primary-button" disabled={generating}>
|
|
||||||
{generating ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Generate SSH Key
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
|
<SSHKeyList
|
||||||
|
keys={keys}
|
||||||
<div className="keys-list">
|
status={status}
|
||||||
{safeKeys.length === 0 ? (
|
signPayloads={signPayloads}
|
||||||
<EmptyState message="No SSH keys yet. Generate one above." />
|
signatures={signatures}
|
||||||
) : (
|
signing={signing}
|
||||||
safeKeys.map((key) => (
|
verifyPayloads={verifyPayloads}
|
||||||
<div key={key.id} className="key-card">
|
verifySignatures={verifySignatures}
|
||||||
<div className="key-header">
|
verifyResults={verifyResults}
|
||||||
<h3>{key.name}</h3>
|
verifying={verifying}
|
||||||
<button
|
onLoadKeys={loadKeys}
|
||||||
onClick={() => handleDelete(key.id)}
|
onDelete={handleDelete}
|
||||||
className="danger-button"
|
onCopy={copyToClipboard}
|
||||||
>
|
onSign={handleSign}
|
||||||
<Icon name="delete" size="sm" />
|
onVerify={handleVerify}
|
||||||
Delete
|
onSignPayloadChange={(id, value) =>
|
||||||
</button>
|
setSignPayloads((prev) => ({ ...prev, [id]: value }))
|
||||||
</div>
|
}
|
||||||
<div className="key-meta">
|
onVerifyPayloadChange={(id, value) =>
|
||||||
<span className="muted">
|
setVerifyPayloads((prev) => ({ ...prev, [id]: value }))
|
||||||
Created: {new Date(key.created_at).toLocaleDateString()}
|
}
|
||||||
</span>
|
onVerifySignatureChange={(id, value) =>
|
||||||
</div>
|
setVerifySignatures((prev) => ({ ...prev, [id]: value }))
|
||||||
<div className="key-public">
|
}
|
||||||
<code>{key.public_key.substring(0, 50)}...</code>
|
/>
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(key.public_key)}
|
|
||||||
className="secondary-button"
|
|
||||||
>
|
|
||||||
<Icon name="copy" size="sm" />
|
|
||||||
Copy Full Key
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="key-signing">
|
|
||||||
<h4>Sign Payload</h4>
|
|
||||||
<div className="form-group">
|
|
||||||
<textarea
|
|
||||||
value={signPayloads[key.id] || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSignPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Enter payload to sign..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSign(key.id)}
|
|
||||||
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
|
|
||||||
className="primary-button"
|
|
||||||
>
|
|
||||||
{signing[key.id] ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Signing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
Sign
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{signatures[key.id] && (
|
|
||||||
<div className="signature-result">
|
|
||||||
<label>Signature (base64):</label>
|
|
||||||
<code>{signatures[key.id]}</code>
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(signatures[key.id])}
|
|
||||||
className="secondary-button"
|
|
||||||
>
|
|
||||||
<Icon name="copy" size="sm" />
|
|
||||||
Copy Signature
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="key-verification">
|
|
||||||
<h4>Verify Signature</h4>
|
|
||||||
<div className="form-group">
|
|
||||||
<textarea
|
|
||||||
value={verifyPayloads[key.id] || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setVerifyPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Enter payload..."
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<textarea
|
|
||||||
value={verifySignatures[key.id] || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setVerifySignatures((prev) => ({ ...prev, [key.id]: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Enter base64 signature..."
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleVerify(key.id)}
|
|
||||||
disabled={
|
|
||||||
verifying[key.id] ||
|
|
||||||
!verifyPayloads[key.id]?.trim() ||
|
|
||||||
!verifySignatures[key.id]?.trim()
|
|
||||||
}
|
|
||||||
className="primary-button"
|
|
||||||
>
|
|
||||||
{verifying[key.id] ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Verifying...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="success" size="sm" />
|
|
||||||
Verify
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
|
|
||||||
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
|
|
||||||
{verifyResults[key.id] ? (
|
|
||||||
<>
|
|
||||||
<Icon name="success" size="sm" />
|
|
||||||
Signature is valid
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="error" size="sm" />
|
|
||||||
Signature is invalid
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+105
-564
@@ -1,571 +1,112 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useTerminalPage } from "../hooks/use-terminal-page";
|
||||||
import { TerminalComponent, type TerminalRef } from "../components/features/terminal/terminal";
|
import { MobileTerminalView } from "../components/features/terminal/MobileTerminalView";
|
||||||
import {
|
import { DesktopTerminalView } from "../components/features/terminal/DesktopTerminalView";
|
||||||
TerminalSessionTabs,
|
|
||||||
type TerminalSessionInfo,
|
|
||||||
} from "../components/features/terminal/terminal-session-tabs";
|
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import { SpecialKeysStrip } from "../components/features/terminal/special-keys-strip";
|
|
||||||
import { SpecialKeysPanel } from "../components/features/terminal/special-keys-panel";
|
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|
||||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
|
||||||
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
|
||||||
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
|
|
||||||
import type { TerminalSession } from "../api/terminal";
|
|
||||||
import type { ModifierKey } from "../hooks/use-special-keys";
|
|
||||||
|
|
||||||
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
|
||||||
sessions.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
status: s.status as TerminalSessionInfo["status"],
|
|
||||||
}));
|
|
||||||
|
|
||||||
type TerminalStatus =
|
|
||||||
| "connecting"
|
|
||||||
| "connected"
|
|
||||||
| "disconnected"
|
|
||||||
| "error"
|
|
||||||
| "resetting";
|
|
||||||
|
|
||||||
export const TerminalPage: React.FC = () => {
|
export const TerminalPage: React.FC = () => {
|
||||||
const { instanceId } = useParams<{
|
const {
|
||||||
instanceId: string;
|
instanceId,
|
||||||
}>();
|
navigate,
|
||||||
const navigate = useNavigate();
|
isMobile,
|
||||||
const isMobile = useMobileViewport();
|
isFullscreen,
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
setIsFullscreen,
|
||||||
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
terminalRefs,
|
||||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
headerAutoHide,
|
||||||
|
terminalStatuses,
|
||||||
|
showResetConfirm,
|
||||||
|
setShowResetConfirm,
|
||||||
|
showSpecialKeysPanel,
|
||||||
|
setShowSpecialKeysPanel,
|
||||||
|
activeModifier,
|
||||||
|
setActiveModifier,
|
||||||
|
isKeyboardOpen,
|
||||||
|
keyboardHeight,
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
handleFullscreenClick,
|
||||||
|
handleSelect,
|
||||||
|
handleClose,
|
||||||
|
handleCreate,
|
||||||
|
handleRename,
|
||||||
|
handleTerminalReady,
|
||||||
|
handleFontSizeChange,
|
||||||
|
handleSendKey,
|
||||||
|
handleReset,
|
||||||
|
sessionInfos,
|
||||||
|
} = useTerminalPage();
|
||||||
|
|
||||||
// Track terminal status and callbacks for unified fullscreen header
|
if (!instanceId) {
|
||||||
const [terminalStatuses, setTerminalStatuses] = useState<
|
return (
|
||||||
Record<string, TerminalStatus>
|
<section className="stack">
|
||||||
>({});
|
<h1>Terminal</h1>
|
||||||
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
<p className="muted">No instance ID provided.</p>
|
||||||
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
</section>
|
||||||
const focusInputRef = useRef<(() => void) | null>(null);
|
);
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
}
|
||||||
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
|
||||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
|
||||||
useVirtualKeyboard();
|
|
||||||
|
|
||||||
const {
|
const status = terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
|
||||||
sessions,
|
|
||||||
activeSessionId,
|
|
||||||
setActiveSessionId,
|
|
||||||
createSession,
|
|
||||||
closeSession,
|
|
||||||
renameSession,
|
|
||||||
resetSession,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
} = useTerminalSessions(instanceId ?? "");
|
|
||||||
|
|
||||||
// Auto-create default session if none exist after loading completes
|
if (isMobile) {
|
||||||
useEffect(() => {
|
return (
|
||||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
<MobileTerminalView
|
||||||
void createSession("Session 1");
|
instanceId={instanceId}
|
||||||
}
|
sessions={sessions}
|
||||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
sessionInfos={sessionInfos}
|
||||||
|
activeSessionId={activeSessionId ?? ""}
|
||||||
|
terminalRefs={terminalRefs}
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
|
loading={loading}
|
||||||
|
isKeyboardOpen={isKeyboardOpen}
|
||||||
|
keyboardHeight={keyboardHeight}
|
||||||
|
isVisible={headerAutoHide.isVisible}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
showSpecialKeysPanel={showSpecialKeysPanel}
|
||||||
|
onToggleHeader={headerAutoHide.toggle}
|
||||||
|
onNavigateBack={() => navigate("/sessions")}
|
||||||
|
onFontSizeChange={handleFontSizeChange}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onClose={handleClose}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
onRename={handleRename}
|
||||||
|
onTerminalReady={handleTerminalReady}
|
||||||
|
onSendKey={handleSendKey}
|
||||||
|
onModifierChange={setActiveModifier}
|
||||||
|
onShowSpecialKeys={() => setShowSpecialKeysPanel(true)}
|
||||||
|
onHideSpecialKeys={() => setShowSpecialKeysPanel(false)}
|
||||||
|
onKeepFocus={() => {
|
||||||
|
/* focus handled by ref */
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure refs map is kept in sync with sessions
|
return (
|
||||||
useEffect(() => {
|
<DesktopTerminalView
|
||||||
for (const session of sessions) {
|
instanceId={instanceId}
|
||||||
if (!terminalRefs.current[session.id]) {
|
sessions={sessions}
|
||||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
sessionInfos={sessionInfos}
|
||||||
}
|
activeSessionId={activeSessionId ?? ""}
|
||||||
}
|
terminalRefs={terminalRefs}
|
||||||
// Clean up refs for closed sessions
|
isFullscreen={isFullscreen}
|
||||||
const currentIds = new Set(sessions.map((s) => s.id));
|
status={status}
|
||||||
for (const id of Object.keys(terminalRefs.current)) {
|
error={error}
|
||||||
if (!currentIds.has(id)) {
|
loading={loading}
|
||||||
delete terminalRefs.current[id];
|
showResetConfirm={showResetConfirm}
|
||||||
}
|
onFullscreenClick={handleFullscreenClick}
|
||||||
}
|
onSelect={handleSelect}
|
||||||
}, [sessions]);
|
onClose={handleClose}
|
||||||
|
onCreate={handleCreate}
|
||||||
// Fit and focus active terminal when switching tabs
|
onRename={handleRename}
|
||||||
useEffect(() => {
|
onNavigateBack={() => navigate("/sessions")}
|
||||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
onToggleFullscreen={() => setIsFullscreen((p) => !p)}
|
||||||
const ref = terminalRefs.current[activeSessionId];
|
onFontSizeChange={handleFontSizeChange}
|
||||||
// Double rAF ensures layout has settled after the display:block switch
|
onShowResetConfirm={() => setShowResetConfirm(true)}
|
||||||
let raf1 = 0;
|
onHideResetConfirm={() => setShowResetConfirm(false)}
|
||||||
let raf2 = 0;
|
onReset={handleReset}
|
||||||
raf1 = requestAnimationFrame(() => {
|
onTerminalReady={handleTerminalReady}
|
||||||
raf2 = requestAnimationFrame(() => {
|
/>
|
||||||
ref.current?.fit();
|
);
|
||||||
ref.current?.focus();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(raf1);
|
|
||||||
cancelAnimationFrame(raf2);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [activeSessionId]);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
|
||||||
if (!isAltShift) return;
|
|
||||||
|
|
||||||
switch (e.key.toLowerCase()) {
|
|
||||||
case "n":
|
|
||||||
e.preventDefault();
|
|
||||||
if (sessions.length < 5) {
|
|
||||||
void createSession(`Session ${sessions.length + 1}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "w":
|
|
||||||
e.preventDefault();
|
|
||||||
if (
|
|
||||||
activeSessionId &&
|
|
||||||
window.confirm("Close this terminal session?")
|
|
||||||
) {
|
|
||||||
void closeSession(activeSessionId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "arrowleft":
|
|
||||||
e.preventDefault();
|
|
||||||
if (activeSessionId) {
|
|
||||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
|
||||||
if (idx > 0) {
|
|
||||||
setActiveSessionId(sessions[idx - 1].id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "arrowright":
|
|
||||||
e.preventDefault();
|
|
||||||
if (activeSessionId) {
|
|
||||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
|
||||||
if (idx < sessions.length - 1) {
|
|
||||||
setActiveSessionId(sessions[idx + 1].id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "r":
|
|
||||||
e.preventDefault();
|
|
||||||
if (activeSessionId) {
|
|
||||||
void resetSession(activeSessionId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "f":
|
|
||||||
e.preventDefault();
|
|
||||||
setIsFullscreen((prev) => !prev);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [
|
|
||||||
sessions,
|
|
||||||
activeSessionId,
|
|
||||||
createSession,
|
|
||||||
closeSession,
|
|
||||||
resetSession,
|
|
||||||
setActiveSessionId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Keep screen awake while terminal is open
|
|
||||||
useEffect(() => {
|
|
||||||
let wakeLock: WakeLockSentinel | null = null;
|
|
||||||
|
|
||||||
const requestWakeLock = async () => {
|
|
||||||
try {
|
|
||||||
if ("wakeLock" in navigator) {
|
|
||||||
wakeLock = await navigator.wakeLock.request("screen");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Wake lock may be denied; silently ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void requestWakeLock();
|
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === "visible") {
|
|
||||||
void requestWakeLock();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
wakeLock?.release().catch(() => {});
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Lock page scroll on mobile terminal so swipes scroll the terminal buffer,
|
|
||||||
// not the page.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMobile) return;
|
|
||||||
document.documentElement.classList.add("terminal-page-open");
|
|
||||||
document.body.classList.add("terminal-page-open");
|
|
||||||
return () => {
|
|
||||||
document.documentElement.classList.remove("terminal-page-open");
|
|
||||||
document.body.classList.remove("terminal-page-open");
|
|
||||||
};
|
|
||||||
}, [isMobile]);
|
|
||||||
|
|
||||||
// Click outside terminal content/header to exit fullscreen
|
|
||||||
const handleFullscreenClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLElement>) => {
|
|
||||||
if (!isFullscreen) return;
|
|
||||||
const target = e.target as Node;
|
|
||||||
const current = e.currentTarget as HTMLElement;
|
|
||||||
const content = current.querySelector(".terminal-page-content");
|
|
||||||
const header = current.querySelector(".terminal-fullscreen-header");
|
|
||||||
if (content?.contains(target) || header?.contains(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsFullscreen(false);
|
|
||||||
},
|
|
||||||
[isFullscreen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(sessionId: string) => {
|
|
||||||
setActiveSessionId(sessionId);
|
|
||||||
},
|
|
||||||
[setActiveSessionId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(
|
|
||||||
async (sessionId: string) => {
|
|
||||||
await closeSession(sessionId);
|
|
||||||
},
|
|
||||||
[closeSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreate = useCallback(() => {
|
|
||||||
void createSession(`Session ${sessions.length + 1}`);
|
|
||||||
}, [createSession, sessions.length]);
|
|
||||||
|
|
||||||
const handleRename = useCallback(
|
|
||||||
(sessionId: string, newName: string) => {
|
|
||||||
void renameSession(sessionId, newName);
|
|
||||||
},
|
|
||||||
[renameSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTerminalReady = useCallback(
|
|
||||||
(
|
|
||||||
sendData: (data: string) => void,
|
|
||||||
status: TerminalStatus,
|
|
||||||
focusInput: () => void,
|
|
||||||
changeFontSize: (delta: number) => void,
|
|
||||||
) => {
|
|
||||||
setTerminalStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[activeSessionId ?? "default"]: status,
|
|
||||||
}));
|
|
||||||
sendDataRef.current = sendData;
|
|
||||||
focusInputRef.current = focusInput;
|
|
||||||
changeFontSizeRef.current = changeFontSize;
|
|
||||||
},
|
|
||||||
[activeSessionId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFontSizeChange = useCallback((delta: number) => {
|
|
||||||
changeFontSizeRef.current?.(delta);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSendKey = useCallback((data: string) => {
|
|
||||||
sendDataRef.current?.(data);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
|
||||||
terminalRefs.current[activeSessionId].current?.reset();
|
|
||||||
}
|
|
||||||
}, [activeSessionId]);
|
|
||||||
|
|
||||||
if (!instanceId) {
|
|
||||||
return (
|
|
||||||
<section className="stack">
|
|
||||||
<h1>Terminal</h1>
|
|
||||||
<p className="muted">No instance ID provided.</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionInfos = SESSIONS_TO_INFO(sessions);
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
|
||||||
const status =
|
|
||||||
terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
|
|
||||||
>
|
|
||||||
{/* Overlay status bar — floats over terminal, never resizes it */}
|
|
||||||
<div
|
|
||||||
className={`mobile-terminal-overlay ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="mobile-terminal-toolbar">
|
|
||||||
<div className="mobile-terminal-toolbar-left">
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => navigate("/sessions")}
|
|
||||||
type="button"
|
|
||||||
aria-label="Back"
|
|
||||||
>
|
|
||||||
<Icon name="arrow-left" size="sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-terminal-toolbar-center">
|
|
||||||
<span className="mobile-terminal-title">
|
|
||||||
{activeSession?.name || "Terminal"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`mobile-terminal-status status-dot ${status}`}
|
|
||||||
aria-label={`Connection status: ${status}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-terminal-toolbar-right">
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => handleFontSizeChange(-1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Decrease font size"
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "0.75rem" }}>A-</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => handleFontSizeChange(1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Increase font size"
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "1rem" }}>A+</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => navigate("/sessions")}
|
|
||||||
type="button"
|
|
||||||
aria-label="Exit terminal"
|
|
||||||
>
|
|
||||||
<Icon name="close" size="sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-terminal-overlay-tabs">
|
|
||||||
<TerminalSessionTabs
|
|
||||||
sessions={sessionInfos}
|
|
||||||
activeSessionId={activeSessionId ?? ""}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onClose={handleClose}
|
|
||||||
onCreate={handleCreate}
|
|
||||||
onRename={handleRename}
|
|
||||||
isMobile={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Terminal content — always fills full viewport */}
|
|
||||||
<div
|
|
||||||
className="terminal-page-content mobile-full"
|
|
||||||
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
|
|
||||||
onClick={() => headerAutoHide.toggle()}
|
|
||||||
>
|
|
||||||
{error && <div className="terminal-error-banner">{error}</div>}
|
|
||||||
{sessions
|
|
||||||
.filter((session) => session.id === activeSessionId)
|
|
||||||
.map((session) => (
|
|
||||||
<div key={session.id} className="terminal-instance active">
|
|
||||||
<TerminalComponent
|
|
||||||
ref={terminalRefs.current[session.id]}
|
|
||||||
instanceId={instanceId}
|
|
||||||
sessionId={session.id}
|
|
||||||
onClose={() => handleClose(session.id)}
|
|
||||||
isMobile={true}
|
|
||||||
showControls={false}
|
|
||||||
activeModifier={activeModifier}
|
|
||||||
onModifierChange={setActiveModifier}
|
|
||||||
onTerminalReady={handleTerminalReady}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sessions.length === 0 && !loading && (
|
|
||||||
<div className="terminal-empty-state">
|
|
||||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SpecialKeysStrip
|
|
||||||
onSend={handleSendKey}
|
|
||||||
isVisible={!showSpecialKeysPanel}
|
|
||||||
onMoreClick={() => setShowSpecialKeysPanel(true)}
|
|
||||||
onKeepFocus={() => focusInputRef.current?.()}
|
|
||||||
activeModifier={activeModifier}
|
|
||||||
onModifierChange={setActiveModifier}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SpecialKeysPanel
|
|
||||||
onSend={handleSendKey}
|
|
||||||
isOpen={showSpecialKeysPanel}
|
|
||||||
onClose={() => setShowSpecialKeysPanel(false)}
|
|
||||||
onKeepFocus={() => focusInputRef.current?.()}
|
|
||||||
activeModifier={activeModifier}
|
|
||||||
onModifierChange={setActiveModifier}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
|
|
||||||
onClick={handleFullscreenClick}
|
|
||||||
>
|
|
||||||
{!isFullscreen && (
|
|
||||||
<div className="terminal-page-header">
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<h1>Terminal</h1>
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => setIsFullscreen((p) => !p)}
|
|
||||||
type="button"
|
|
||||||
title="Toggle fullscreen (Alt+Shift+F)"
|
|
||||||
>
|
|
||||||
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isFullscreen ? (
|
|
||||||
<div className="terminal-fullscreen-header">
|
|
||||||
<div className="terminal-fullscreen-header-tabs">
|
|
||||||
<TerminalSessionTabs
|
|
||||||
sessions={sessionInfos}
|
|
||||||
activeSessionId={activeSessionId ?? ""}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onClose={handleClose}
|
|
||||||
onCreate={handleCreate}
|
|
||||||
onRename={handleRename}
|
|
||||||
isMobile={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="terminal-fullscreen-header-controls">
|
|
||||||
<span
|
|
||||||
className={`terminal-fullscreen-status status-dot ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
|
|
||||||
aria-label={`Terminal status: ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="terminal-header-button"
|
|
||||||
onClick={() => handleFontSizeChange(-1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Decrease font size"
|
|
||||||
>
|
|
||||||
A-
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-header-button"
|
|
||||||
onClick={() => handleFontSizeChange(1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Increase font size"
|
|
||||||
>
|
|
||||||
A+
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-header-button"
|
|
||||||
onClick={() => setShowResetConfirm(true)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Reset terminal"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-close"
|
|
||||||
onClick={() => setIsFullscreen(false)}
|
|
||||||
type="button"
|
|
||||||
title="Exit fullscreen (Esc)"
|
|
||||||
>
|
|
||||||
Exit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showResetConfirm && (
|
|
||||||
<div className="terminal-reset-confirm">
|
|
||||||
<div className="terminal-reset-confirm-content">
|
|
||||||
<p>
|
|
||||||
Reset terminal? This will kill the current shell session and
|
|
||||||
start fresh.
|
|
||||||
</p>
|
|
||||||
<div className="terminal-reset-confirm-buttons">
|
|
||||||
<button
|
|
||||||
className="terminal-reset-confirm-button cancel"
|
|
||||||
onClick={() => setShowResetConfirm(false)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-reset-confirm-button confirm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowResetConfirm(false);
|
|
||||||
handleReset();
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<TerminalSessionTabs
|
|
||||||
sessions={sessionInfos}
|
|
||||||
activeSessionId={activeSessionId ?? ""}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onClose={handleClose}
|
|
||||||
onCreate={handleCreate}
|
|
||||||
onRename={handleRename}
|
|
||||||
isMobile={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="terminal-page-content">
|
|
||||||
{error && <div className="terminal-error-banner">{error}</div>}
|
|
||||||
{sessions
|
|
||||||
.filter((session) => session.id === activeSessionId)
|
|
||||||
.map((session) => (
|
|
||||||
<div key={session.id} className="terminal-instance active">
|
|
||||||
<TerminalComponent
|
|
||||||
ref={terminalRefs.current[session.id]}
|
|
||||||
instanceId={instanceId}
|
|
||||||
sessionId={session.id}
|
|
||||||
onClose={() => handleClose(session.id)}
|
|
||||||
isMobile={false}
|
|
||||||
showControls={!isFullscreen}
|
|
||||||
onTerminalReady={handleTerminalReady}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sessions.length === 0 && !loading && (
|
|
||||||
<div className="terminal-empty-state">
|
|
||||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
|||||||
|
/* Git History Page Styles */
|
||||||
|
.history-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-selector {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-list.with-detail {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-item:hover {
|
||||||
|
background: #ece7df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-item.selected {
|
||||||
|
border-color: var(--brand);
|
||||||
|
background: #f0f7f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-graph {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--brand);
|
||||||
|
white-space: pre;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-line {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-hash {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--brand);
|
||||||
|
background: #f0f7f4;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-refs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-message {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-detail-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-hash-full {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--brand);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-message-full {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f5f3ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat.additions {
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat.deletions {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat.additions .stat-value {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat.deletions .stat-value {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-hash {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
background: #f5f3ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-content {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: #f5f3ee;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.history-container {
|
||||||
|
grid-template-columns: 1fr 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-list.with-detail {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-detail-panel {
|
||||||
|
grid-column: 2;
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/* ─── Projects Page Refresh ─── */
|
||||||
|
|
||||||
|
.project-info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-toggle:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-toggle h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-count {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip a {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-branch {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-instances {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-actions button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-actions button:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-actions button.danger-text:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,709 @@
|
|||||||
|
/* Repository Workspace */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Tree */
|
||||||
|
.file-tree {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-entry {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-entry:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-directory {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-up {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Viewer */
|
||||||
|
.file-viewer {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-viewer-header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-breadcrumbs {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-sep {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-viewer-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Status Indicators */
|
||||||
|
.file-status-indicator {
|
||||||
|
float: right;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status-indicator.modified {
|
||||||
|
color: #f59e0b;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status-indicator.added {
|
||||||
|
color: #10b981;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status-indicator.deleted {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status-indicator.untracked {
|
||||||
|
color: #6b7280;
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Commit Panel */
|
||||||
|
.commit-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-panel h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
width: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.modified .file-status {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.file-item.added .file-status {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
.file-item.deleted .file-status {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.file-item.untracked .file-status {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-message-input {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Merge Dialog */
|
||||||
|
.merge-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-form .form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-form label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-form select,
|
||||||
|
.merge-form input,
|
||||||
|
.merge-form textarea {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-form textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Git Toolbar - Top Bar Styles */
|
||||||
|
.git-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button:hover:not(:disabled) {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button.primary {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-select {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-input {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-branch-form {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-summary {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.modified {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.added {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.deleted {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.untracked {
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Editor */
|
||||||
|
.file-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Git Mount Editor Styles */
|
||||||
|
.git-mount-editor {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-editor .section-subtitle {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-item {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-repo {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-paths {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-branch {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-add {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-add h5 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row input,
|
||||||
|
.git-mount-form .form-row select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row input.error,
|
||||||
|
.git-mount-form .form-row select.error {
|
||||||
|
border-color: #cd3131;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row .hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row .error-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #cd3131;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-form input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* Mobile Sessions Page */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sessions-page {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-page .page-header {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-page .page-header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-session-section {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-session-section h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-section {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-section h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions.mobile {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions.mobile .mobile-primary {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions.mobile .mobile-more {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.create-session-form .form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-form input,
|
||||||
|
.create-session-form select,
|
||||||
|
.create-session-form textarea,
|
||||||
|
.create-session-form button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/* SSH keys table responsive */
|
||||||
|
.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,504 @@
|
|||||||
|
/* ─── Workspace Detail Page ─── */
|
||||||
|
|
||||||
|
.workspace-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-link {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-breadcrumb .sep {
|
||||||
|
color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-breadcrumb strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Bar */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Tab Bar */
|
||||||
|
.mobile-tab-bar {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab.active {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspace Content */
|
||||||
|
.workspace-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Files Tab */
|
||||||
|
.files-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-toolbar-status {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-toolbar-status span {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-modified {
|
||||||
|
background: var(--warning-light);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-added {
|
||||||
|
background: var(--success-light);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-deleted {
|
||||||
|
background: var(--danger-light);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-untracked {
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-toolbar-actions input {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree {
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-entry:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-entry.selected {
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-viewer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-4);
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: none;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: none;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Git Tab */
|
||||||
|
.git-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-tab-header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-tab-header select {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-history {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr 120px 120px;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-hash {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-message {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-author,
|
||||||
|
.commit-date {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tools Tab */
|
||||||
|
.tools-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-10);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instances-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-card.running {
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Tab */
|
||||||
|
.settings-tab {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-5);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Workspace Detail */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.workspace-detail.mobile .workspace-content {
|
||||||
|
padding-bottom: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab-bar {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspace Card Link */
|
||||||
|
.workspace-header-link {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
margin: -1rem -1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-link:hover .workspace-header h4 {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Workspace Create Inline ─── */
|
||||||
|
|
||||||
|
.workspace-create-inline {
|
||||||
|
padding: var(--space-5);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-inline h3 {
|
||||||
|
margin: 0 0 var(--space-4) 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-form-grid .form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-form-grid label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-form-grid input,
|
||||||
|
.workspace-create-form-grid select {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-form-grid .form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/* Syntax Highlighter */
|
||||||
|
.syntax-highlighter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlighter-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:hover {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: "Fira Code", "Monaco", "Courier New", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-numbers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
min-width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-number {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: visible;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block code {
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Editor */
|
||||||
|
.code-editor {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-textarea {
|
||||||
|
font-family: "Fira Code", "Monaco", "Courier New", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-textarea-input {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
caret-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-line {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-line-number {
|
||||||
|
display: inline-block;
|
||||||
|
width: 3rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--muted);
|
||||||
|
user-select: none;
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-line-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prism.js Theme Integration */
|
||||||
|
code[class*="language-"],
|
||||||
|
pre[class*="language-"] {
|
||||||
|
color: var(--ink);
|
||||||
|
text-shadow: none;
|
||||||
|
font-family: "Fira Code", "Monaco", "Courier New", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre;
|
||||||
|
word-spacing: normal;
|
||||||
|
word-break: normal;
|
||||||
|
word-wrap: normal;
|
||||||
|
line-height: 1.5;
|
||||||
|
tab-size: 2;
|
||||||
|
hyphens: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syntax Highlighting Colors */
|
||||||
|
.token.comment,
|
||||||
|
.token.prolog,
|
||||||
|
.token.doctype,
|
||||||
|
.token.cdata {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.punctuation {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.namespace {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.property,
|
||||||
|
.token.tag,
|
||||||
|
.token.boolean,
|
||||||
|
.token.number,
|
||||||
|
.token.constant,
|
||||||
|
.token.symbol,
|
||||||
|
.token.deleted {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.selector,
|
||||||
|
.token.attr-name,
|
||||||
|
.token.string,
|
||||||
|
.token.char,
|
||||||
|
.token.builtin,
|
||||||
|
.token.inserted {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.operator,
|
||||||
|
.token.entity,
|
||||||
|
.token.url,
|
||||||
|
.language-css .token.string,
|
||||||
|
.style .token.string {
|
||||||
|
color: #f43f5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.atrule,
|
||||||
|
.token.attr-value,
|
||||||
|
.token.keyword {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.function,
|
||||||
|
.token.class-name {
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.regex,
|
||||||
|
.token.important,
|
||||||
|
.token.variable {
|
||||||
|
color: #ec4899;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
|
--bg: #f4f1ea;
|
||||||
|
--panel: #fffef9;
|
||||||
|
--ink: #1d1d1b;
|
||||||
|
--muted: #5f5b55;
|
||||||
|
--brand: #275d4b;
|
||||||
|
--brand-strong: #154236;
|
||||||
|
--border: #d8d0c5;
|
||||||
|
--primary: #275d4b;
|
||||||
|
--primary-fg: #fffef9;
|
||||||
|
--color-primary: #275d4b;
|
||||||
|
--success: #2f8f62;
|
||||||
|
--success-light: rgba(47, 143, 98, 0.14);
|
||||||
|
--warning: #c08a1e;
|
||||||
|
--warning-light: rgba(192, 138, 30, 0.14);
|
||||||
|
--danger: #b94a3c;
|
||||||
|
--danger-light: rgba(185, 74, 60, 0.14);
|
||||||
|
--info: #4f7fb8;
|
||||||
|
--info-light: rgba(79, 127, 184, 0.14);
|
||||||
|
|
||||||
|
/* Spacing Scale (4px base) */
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-5: 1.5rem;
|
||||||
|
--space-6: 2rem;
|
||||||
|
--space-8: 3rem;
|
||||||
|
--space-10: 4rem;
|
||||||
|
|
||||||
|
/* Breakpoints */
|
||||||
|
--bp-sm: 480px;
|
||||||
|
--bp-md: 768px;
|
||||||
|
--bp-lg: 1024px;
|
||||||
|
--bp-xl: 1280px;
|
||||||
|
|
||||||
|
/* Fluid Typography */
|
||||||
|
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
|
||||||
|
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
|
||||||
|
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
|
||||||
|
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
|
||||||
|
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
|
||||||
|
--font-size-2xl: clamp(1.5rem, 1.3rem + 1vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #171613;
|
||||||
|
--panel: #22201d;
|
||||||
|
--ink: #ece7df;
|
||||||
|
--muted: #a59d92;
|
||||||
|
--brand: #5fa889;
|
||||||
|
--brand-strong: #4d9175;
|
||||||
|
--border: #39342d;
|
||||||
|
--primary: #5fa889;
|
||||||
|
--primary-fg: #171613;
|
||||||
|
--color-primary: #5fa889;
|
||||||
|
--success: #22c55e;
|
||||||
|
--success-light: rgba(34, 197, 94, 0.15);
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--warning-light: rgba(245, 158, 11, 0.15);
|
||||||
|
--danger: #ef4444;
|
||||||
|
--danger-light: rgba(239, 68, 68, 0.15);
|
||||||
|
--info: #3b82f6;
|
||||||
|
--info-light: rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.terminal-page-open,
|
||||||
|
body.terminal-page-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] body {
|
||||||
|
background: radial-gradient(circle at top right, #2a2520, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user