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."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
@@ -12,10 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.project import Project
|
||||
from src.models import ToolType
|
||||
from src.models import UserConfig
|
||||
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
||||
from src.schemas.config import (
|
||||
ConfigProfileCreate,
|
||||
ConfigProfileIncludeUpdate,
|
||||
@@ -31,121 +25,25 @@ from src.services.config.config_profile_resolver import (
|
||||
resolve_profile,
|
||||
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__)
|
||||
|
||||
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])
|
||||
async def list_config_profiles(
|
||||
@@ -165,26 +63,21 @@ async def list_config_profiles(
|
||||
)
|
||||
|
||||
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
|
||||
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
conditions: list = []
|
||||
# Portable profiles (no project, no tool)
|
||||
conditions.append(
|
||||
(ConfigProfile.project_id.is_(None))
|
||||
& (ConfigProfile.tool_type_id.is_(None))
|
||||
)
|
||||
if project_uuid:
|
||||
# Profiles matching this project (with or without tool)
|
||||
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||
if tool_uuid:
|
||||
# Profiles matching this tool (with or without project)
|
||||
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||
if project_uuid and tool_uuid:
|
||||
# Exact match
|
||||
conditions.append(
|
||||
(ConfigProfile.project_id == project_uuid)
|
||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||
@@ -194,7 +87,7 @@ async def list_config_profiles(
|
||||
|
||||
result = await session.execute(query)
|
||||
profiles = result.scalars().all()
|
||||
return [_profile_to_response(p) for p in profiles]
|
||||
return [profile_to_response(p) for p in profiles]
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -208,7 +101,6 @@ async def create_config_profile(
|
||||
"""Create a new config profile."""
|
||||
user_uuid = current_user_id
|
||||
|
||||
# Check for duplicate name
|
||||
existing = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(
|
||||
@@ -223,24 +115,21 @@ async def create_config_profile(
|
||||
detail=f"Profile with name '{data.name}' already exists",
|
||||
)
|
||||
|
||||
# Validate references
|
||||
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
|
||||
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:
|
||||
git_mounts_data = [
|
||||
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:
|
||||
raise HTTPException(
|
||||
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(
|
||||
@@ -259,7 +148,6 @@ async def create_config_profile(
|
||||
session.add(profile)
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch with includes to avoid lazy loading issues
|
||||
result = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.id == profile.id)
|
||||
@@ -268,7 +156,7 @@ async def create_config_profile(
|
||||
profile = result.scalar_one()
|
||||
|
||||
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)
|
||||
@@ -278,7 +166,7 @@ async def get_config_profile(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
@@ -287,7 +175,7 @@ async def get_config_profile(
|
||||
raise HTTPException(
|
||||
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)
|
||||
@@ -298,7 +186,7 @@ async def update_config_profile(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""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:
|
||||
raise HTTPException(
|
||||
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)
|
||||
|
||||
# Handle name uniqueness
|
||||
if "name" in update_data:
|
||||
existing = await session.execute(
|
||||
select(ConfigProfile).where(
|
||||
@@ -325,7 +212,6 @@ async def update_config_profile(
|
||||
detail=f"Profile with name '{update_data['name']}' already exists",
|
||||
)
|
||||
|
||||
# Validate references
|
||||
project_uuid = (
|
||||
uuid.UUID(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"]
|
||||
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:
|
||||
git_mounts_data = [
|
||||
m.model_dump() if hasattr(m, "model_dump") else m
|
||||
for m in update_data["git_mounts"]
|
||||
]
|
||||
await _validate_git_mounts(
|
||||
await validate_git_mounts(
|
||||
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}
|
||||
size = _calculate_profile_size(merged)
|
||||
size = calculate_profile_size(merged)
|
||||
if size > MAX_PROFILE_SIZE_BYTES:
|
||||
raise HTTPException(
|
||||
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():
|
||||
if field_name in ("project_id", "tool_type_id"):
|
||||
value = uuid.UUID(value) if value else None
|
||||
@@ -370,7 +253,6 @@ async def update_config_profile(
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch with includes to avoid lazy loading issues
|
||||
result = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.id == profile.id)
|
||||
@@ -379,7 +261,7 @@ async def update_config_profile(
|
||||
profile = result.scalar_one()
|
||||
|
||||
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)
|
||||
@@ -389,7 +271,7 @@ async def delete_config_profile(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""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:
|
||||
raise HTTPException(
|
||||
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),
|
||||
):
|
||||
"""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:
|
||||
raise HTTPException(
|
||||
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"
|
||||
)
|
||||
|
||||
# Validate all included profiles exist and belong to the user
|
||||
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||
for inc_uuid in included_uuids:
|
||||
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||
@@ -444,10 +325,8 @@ async def update_profile_includes(
|
||||
detail="Profile cannot include itself",
|
||||
)
|
||||
|
||||
# Check for cycles
|
||||
cycle = await check_include_cycle(session, profile.id, None)
|
||||
if cycle is None and included_uuids:
|
||||
# Check each new include would not create a cycle
|
||||
for inc_uuid in included_uuids:
|
||||
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
||||
if cycle is not None:
|
||||
@@ -460,7 +339,6 @@ async def update_profile_includes(
|
||||
detail=f"Include cycle detected: {cycle_str}",
|
||||
)
|
||||
|
||||
# Remove existing includes
|
||||
result = await session.execute(
|
||||
select(ConfigProfileInclude).where(
|
||||
ConfigProfileInclude.profile_id == profile.id
|
||||
@@ -470,7 +348,6 @@ async def update_profile_includes(
|
||||
await session.delete(existing)
|
||||
await session.flush()
|
||||
|
||||
# Add new includes
|
||||
for order_index, inc_uuid in enumerate(included_uuids):
|
||||
include = ConfigProfileInclude(
|
||||
profile_id=profile.id,
|
||||
@@ -479,10 +356,8 @@ async def update_profile_includes(
|
||||
)
|
||||
session.add(include)
|
||||
await session.flush()
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch profile (includes loaded separately due to SQLite async issue)
|
||||
result = await session.execute(
|
||||
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||
)
|
||||
@@ -496,7 +371,7 @@ async def update_profile_includes(
|
||||
direct_includes = inc_result.scalars().all()
|
||||
|
||||
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")
|
||||
@@ -506,7 +381,7 @@ async def preview_config_profile(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
@@ -528,120 +403,19 @@ async def preview_config_profile(
|
||||
|
||||
|
||||
@router.get("/defaults/resolve")
|
||||
async def resolve_default_profile(
|
||||
async def resolve_default_profile_endpoint(
|
||||
project_id: str = Query(..., description="Project ID"),
|
||||
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Resolve the default config profile for a project/tool combination.
|
||||
|
||||
Selects by specificity:
|
||||
1. project+tool explicit default
|
||||
2. project explicit default
|
||||
3. tool explicit default
|
||||
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)
|
||||
"""Resolve the default config profile for a project/tool combination."""
|
||||
return await resolve_default_profile(
|
||||
session,
|
||||
current_user_id,
|
||||
uuid.UUID(project_id),
|
||||
uuid.UUID(tool_type_id),
|
||||
)
|
||||
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")
|
||||
@@ -664,8 +438,8 @@ async def set_default_profiles_endpoint(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Set default profile mappings for the current user."""
|
||||
await _validate_default_profiles(session, user_id, data.default_profiles)
|
||||
user_config = await _get_or_create_user_config(session, user_id)
|
||||
await validate_default_profiles(session, user_id, data.default_profiles)
|
||||
user_config = await get_or_create_user_config(session, user_id)
|
||||
user_config.config = {
|
||||
**user_config.config,
|
||||
"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)
|
||||
async def validate_git_url(
|
||||
async def validate_git_url_endpoint(
|
||||
data: ValidateGitUrlRequest,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ValidateGitUrlResponse:
|
||||
"""Validate a git remote URL and list available branches.
|
||||
|
||||
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,
|
||||
)
|
||||
"""Validate a git remote URL and list available branches."""
|
||||
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
|
||||
|
||||
@@ -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_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
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||
@@ -51,179 +58,6 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||
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(
|
||||
"/repositories",
|
||||
response_model=list[GitRepositoryResponse],
|
||||
@@ -350,7 +184,7 @@ async def create_external_repository(
|
||||
)
|
||||
|
||||
if remote_url:
|
||||
_preflight_remote_repository(remote_url, ssh_key)
|
||||
preflight_remote_repository(remote_url, ssh_key)
|
||||
|
||||
# Create external repo with no project
|
||||
repo = GitRepository(
|
||||
@@ -370,7 +204,7 @@ async def create_external_repository(
|
||||
|
||||
if remote_url:
|
||||
try:
|
||||
_clone_working_repository(remote_url, repo_path, ssh_key)
|
||||
clone_working_repository(remote_url, repo_path, ssh_key)
|
||||
repo.is_mirror = False
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
@@ -541,17 +375,17 @@ async def create_repository(
|
||||
)
|
||||
|
||||
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
|
||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||
|
||||
if remote_url:
|
||||
_clone_working_repository(remote_url, repo_path, ssh_key)
|
||||
clone_working_repository(remote_url, repo_path, ssh_key)
|
||||
else:
|
||||
_init_working_repository(repo_path)
|
||||
init_working_repository(repo_path)
|
||||
|
||||
repo = GitRepository(
|
||||
name=data.name,
|
||||
@@ -969,7 +803,7 @@ async def get_repository_branches(
|
||||
if 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
|
||||
key_path = None
|
||||
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 { AuthProvider } from "./state/auth";
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<SessionsProvider>
|
||||
<AppRouter />
|
||||
</SessionsProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<SessionsProvider>
|
||||
<AppRouter />
|
||||
</SessionsProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +1,37 @@
|
||||
/** Projects page with inline repositories and workspaces. */
|
||||
|
||||
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 { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
||||
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
import { ProjectCard } from "../components/features/project/ProjectCard";
|
||||
import { ProjectDialog } from "../components/features/project/ProjectDialog";
|
||||
import { useProjects } from "../hooks/use-projects";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const {
|
||||
data: projects,
|
||||
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);
|
||||
dialogMode,
|
||||
formName,
|
||||
setFormName,
|
||||
formDescription,
|
||||
setFormDescription,
|
||||
formError,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
expandedProject,
|
||||
setExpandedProject,
|
||||
creatingWorkspace,
|
||||
setCreatingWorkspace,
|
||||
workspaceLoading,
|
||||
openCreate,
|
||||
openEdit,
|
||||
closeDialog,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handleSyncWorkspace,
|
||||
handleDeleteWorkspace,
|
||||
} = useProjects();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||
const isEmpty = status === "ready" && projects.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -159,13 +53,20 @@ export const ProjectsPage = () => {
|
||||
<EmptyState message="No projects yet. Create your first project above." />
|
||||
)}
|
||||
|
||||
{status === "ready" && safeProjects.length > 0 && (
|
||||
{status === "ready" && projects.length > 0 && (
|
||||
<div className="project-list">
|
||||
{safeProjects.map((project) => (
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
expanded={expandedProject === project.id}
|
||||
deleteConfirm={deleteConfirmId === project.id}
|
||||
workspaceLoading={workspaceLoading}
|
||||
showCreateForm={
|
||||
creatingWorkspace?.projectId === project.id
|
||||
? creatingWorkspace.repoId
|
||||
: null
|
||||
}
|
||||
onToggle={() =>
|
||||
setExpandedProject(
|
||||
expandedProject === project.id ? null : project.id,
|
||||
@@ -173,7 +74,6 @@ export const ProjectsPage = () => {
|
||||
}
|
||||
onEdit={() => openEdit(project)}
|
||||
onDelete={() => setDeleteConfirmId(project.id)}
|
||||
deleteConfirm={deleteConfirmId === project.id}
|
||||
onConfirmDelete={() => void handleDelete(project.id)}
|
||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||
onCreateWorkspace={(repoId) =>
|
||||
@@ -186,12 +86,6 @@ export const ProjectsPage = () => {
|
||||
void handleDeleteWorkspace(workspace);
|
||||
}
|
||||
}}
|
||||
workspaceLoading={workspaceLoading}
|
||||
showCreateForm={
|
||||
creatingWorkspace?.projectId === project.id
|
||||
? creatingWorkspace.repoId
|
||||
: null
|
||||
}
|
||||
onCancelCreate={() => setCreatingWorkspace(null)}
|
||||
onCreated={() => {
|
||||
setCreatingWorkspace(null);
|
||||
@@ -203,231 +97,17 @@ export const ProjectsPage = () => {
|
||||
)}
|
||||
|
||||
{dialogMode !== "none" && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>
|
||||
{dialogMode === "create" ? "Create Project" : "Edit Project"}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
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>
|
||||
<ProjectDialog
|
||||
mode={dialogMode}
|
||||
name={formName}
|
||||
description={formDescription}
|
||||
error={formError}
|
||||
onNameChange={setFormName}
|
||||
onDescriptionChange={setFormDescription}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
)}
|
||||
</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 { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
|
||||
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 { useRepoWorkspace } from "../hooks/use-repo-workspace";
|
||||
import { WorkspaceHeader } from "../components/features/workspace/workspace-header";
|
||||
import { listToolTypes } from "../api/tool-types";
|
||||
import type { ToolType } from "../api/tool-types";
|
||||
import { WorkspaceLayout } from "../components/features/workspace/WorkspaceLayout";
|
||||
|
||||
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 = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isMobile = useMobileViewport();
|
||||
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
||||
const {
|
||||
projectId,
|
||||
project,
|
||||
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");
|
||||
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[]>([]);
|
||||
return (
|
||||
<section className="repo-workspace">
|
||||
{project && (
|
||||
<WorkspaceHeader
|
||||
project={project}
|
||||
currentRepo={selectedRepo || null}
|
||||
/>
|
||||
)}
|
||||
|
||||
const loadProject = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const response = await apiClient.get(`/projects/${projectId}`);
|
||||
setProject(response.data);
|
||||
} catch {
|
||||
setProject(null);
|
||||
}
|
||||
}, [projectId]);
|
||||
{status === "loading" && (
|
||||
<LoadingState message="Loading repositories..." />
|
||||
)}
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Failed to load repositories"
|
||||
onRetry={() => void loadRepositories()}
|
||||
/>
|
||||
)}
|
||||
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
{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>
|
||||
)}
|
||||
|
||||
if (data.length === 0) {
|
||||
setStatus("empty");
|
||||
} else {
|
||||
setStatus("ready");
|
||||
// If no repo selected, select the first one
|
||||
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 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>
|
||||
);
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<WorkspaceLayout
|
||||
projectId={projectId!}
|
||||
project={project}
|
||||
isMobile={isMobile}
|
||||
mobileTab={mobileTab}
|
||||
selectedRepoId={selectedRepoId}
|
||||
selectedRepo={selectedRepo}
|
||||
branches={branches}
|
||||
currentBranch={currentBranch}
|
||||
gitStatus={gitStatus}
|
||||
toolTypes={toolTypes}
|
||||
repositories={repositories}
|
||||
onMobileTabChange={setMobileTab}
|
||||
onRepoChange={handleRepoChange}
|
||||
onBranchChange={handleBranchChange}
|
||||
onRefresh={() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||
|
||||
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||
import {
|
||||
getUserConfig,
|
||||
updateUserConfig,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
type UserConfigUpdate,
|
||||
} from "../api/settings";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
const TABS = [
|
||||
@@ -16,29 +14,7 @@ const TABS = [
|
||||
{ label: "SSH Keys", path: "ssh-keys" },
|
||||
] as const;
|
||||
|
||||
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"];
|
||||
|
||||
type SettingsOutletContext = {
|
||||
config: UserConfig;
|
||||
handleChange: (
|
||||
key: keyof UserConfigUpdate,
|
||||
value: string | string[] | null,
|
||||
) => void;
|
||||
handleSave: () => Promise<void>;
|
||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||
};
|
||||
export { GeneralSettingsTab } from "../components/features/settings/GeneralSettingsTab";
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
@@ -60,7 +36,6 @@ export const SettingsPage = () => {
|
||||
"idle" | "saving" | "saved" | "error"
|
||||
>("idle");
|
||||
|
||||
// Sync loaded config into local editable state
|
||||
useEffect(() => {
|
||||
if (loadedConfig) {
|
||||
setConfig({
|
||||
@@ -160,125 +135,3 @@ export const SettingsPage = () => {
|
||||
</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 { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh-keys";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import { LoadingState } from "../components/data-states";
|
||||
import { useSSHKeys } from "../hooks/use-ssh-keys";
|
||||
import { SSHKeyCreateForm } from "../components/features/ssh-keys/SSHKeyCreateForm";
|
||||
import { SSHKeyList } from "../components/features/ssh-keys/SSHKeyList";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
const {
|
||||
keys,
|
||||
status,
|
||||
loadKeys,
|
||||
newKeyName,
|
||||
setNewKeyName,
|
||||
generating,
|
||||
mutationError,
|
||||
signPayloads,
|
||||
signatures,
|
||||
signing,
|
||||
verifyPayloads,
|
||||
verifySignatures,
|
||||
verifyResults,
|
||||
verifying,
|
||||
handleGenerate,
|
||||
handleDelete,
|
||||
copyToClipboard,
|
||||
handleSign,
|
||||
handleVerify,
|
||||
setSignPayloads,
|
||||
setVerifyPayloads,
|
||||
setVerifySignatures,
|
||||
} = useSSHKeys();
|
||||
|
||||
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
||||
|
||||
@@ -104,174 +47,38 @@ export const SSHKeysPage = () => {
|
||||
|
||||
{mutationError && <div className="error">{mutationError}</div>}
|
||||
|
||||
<form onSubmit={handleGenerate} 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>
|
||||
<SSHKeyCreateForm
|
||||
newKeyName={newKeyName}
|
||||
setNewKeyName={setNewKeyName}
|
||||
generating={generating}
|
||||
onSubmit={handleGenerate}
|
||||
/>
|
||||
|
||||
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
|
||||
|
||||
<div className="keys-list">
|
||||
{safeKeys.length === 0 ? (
|
||||
<EmptyState message="No SSH keys yet. Generate one above." />
|
||||
) : (
|
||||
safeKeys.map((key) => (
|
||||
<div key={key.id} className="key-card">
|
||||
<div className="key-header">
|
||||
<h3>{key.name}</h3>
|
||||
<button
|
||||
onClick={() => handleDelete(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={() => 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>
|
||||
<SSHKeyList
|
||||
keys={keys}
|
||||
status={status}
|
||||
signPayloads={signPayloads}
|
||||
signatures={signatures}
|
||||
signing={signing}
|
||||
verifyPayloads={verifyPayloads}
|
||||
verifySignatures={verifySignatures}
|
||||
verifyResults={verifyResults}
|
||||
verifying={verifying}
|
||||
onLoadKeys={loadKeys}
|
||||
onDelete={handleDelete}
|
||||
onCopy={copyToClipboard}
|
||||
onSign={handleSign}
|
||||
onVerify={handleVerify}
|
||||
onSignPayloadChange={(id, value) =>
|
||||
setSignPayloads((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
onVerifyPayloadChange={(id, value) =>
|
||||
setVerifyPayloads((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
onVerifySignatureChange={(id, value) =>
|
||||
setVerifySignatures((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
+105
-564
@@ -1,571 +1,112 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { TerminalComponent, type TerminalRef } from "../components/features/terminal/terminal";
|
||||
import {
|
||||
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";
|
||||
import React from "react";
|
||||
import { useTerminalPage } from "../hooks/use-terminal-page";
|
||||
import { MobileTerminalView } from "../components/features/terminal/MobileTerminalView";
|
||||
import { DesktopTerminalView } from "../components/features/terminal/DesktopTerminalView";
|
||||
|
||||
export const TerminalPage: React.FC = () => {
|
||||
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 {
|
||||
instanceId,
|
||||
navigate,
|
||||
isMobile,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
terminalRefs,
|
||||
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
|
||||
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();
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Terminal</h1>
|
||||
<p className="muted">No instance ID provided.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
resetSession,
|
||||
loading,
|
||||
error,
|
||||
} = useTerminalSessions(instanceId ?? "");
|
||||
const status = terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
|
||||
|
||||
// Auto-create default session if none exist after loading completes
|
||||
useEffect(() => {
|
||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||
void createSession("Session 1");
|
||||
}
|
||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileTerminalView
|
||||
instanceId={instanceId}
|
||||
sessions={sessions}
|
||||
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
|
||||
useEffect(() => {
|
||||
for (const session of sessions) {
|
||||
if (!terminalRefs.current[session.id]) {
|
||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
||||
}
|
||||
}
|
||||
// Clean up refs for closed sessions
|
||||
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 when switching tabs
|
||||
useEffect(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
const ref = terminalRefs.current[activeSessionId];
|
||||
// Double rAF ensures layout has settled after the display:block switch
|
||||
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;
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<DesktopTerminalView
|
||||
instanceId={instanceId}
|
||||
sessions={sessions}
|
||||
sessionInfos={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
terminalRefs={terminalRefs}
|
||||
isFullscreen={isFullscreen}
|
||||
status={status}
|
||||
error={error}
|
||||
loading={loading}
|
||||
showResetConfirm={showResetConfirm}
|
||||
onFullscreenClick={handleFullscreenClick}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
onNavigateBack={() => navigate("/sessions")}
|
||||
onToggleFullscreen={() => setIsFullscreen((p) => !p)}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onShowResetConfirm={() => setShowResetConfirm(true)}
|
||||
onHideResetConfirm={() => setShowResetConfirm(false)}
|
||||
onReset={handleReset}
|
||||
onTerminalReady={handleTerminalReady}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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