Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44ef62271e | |||
| 7440720b7b | |||
| 110844e597 | |||
| b4d08b0232 | |||
| f1180f6053 | |||
| 59bec31046 | |||
| 8a7bec8df2 | |||
| c881bbdad3 | |||
| f1b968bb88 | |||
| 3c222d4f0f | |||
| 1d10283fc9 | |||
| 886be83af5 | |||
| 82091e31a8 | |||
| b2c84e2064 | |||
| 680417a0a2 | |||
| e5e29aca49 | |||
| 486f3cbc44 | |||
| 152f87a254 | |||
| 349066bcfa | |||
| c353bceb97 | |||
| 64dcdc9d0d | |||
| 4ea2e3659d | |||
| 734bd9529a | |||
| 1ef9d66eed | |||
| 2169b24875 | |||
| f2a3399f27 | |||
| 5266e64be2 | |||
| 9a17916dd2 | |||
| a388a8bec9 | |||
| 51a98a0c63 | |||
| 6efe524974 | |||
| 88c56a83b7 | |||
| b4aa4c5fcb | |||
| 183e910afd | |||
| d80ee4157c | |||
| d8ab7734cb | |||
| de6a6a3b00 | |||
| 9503f6cb4f | |||
| 6b118307eb | |||
| 070e960c05 | |||
| 96ce3f4c53 | |||
| e49d049455 | |||
| e7f219f7c3 | |||
| d7d5baa41a | |||
| 61072f4c07 | |||
| 7070867393 | |||
| d472c41092 | |||
| a9e2dd3552 | |||
| 6553a8845b | |||
| 994b1cf3b7 | |||
| 6aea83bf17 | |||
| 8d51877afa | |||
| 597cfb9573 |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812"
|
"fingerprint": "639c16d45210921c3c8ece071ef18bbe0c426ea2"
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
# Skill Registry — headquarter
|
# Skill Registry — workspace
|
||||||
|
|
||||||
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
||||||
|
|
||||||
Last updated: 2026-06-02
|
Last updated: 2026-06-05
|
||||||
|
|
||||||
## Sources scanned
|
## Sources scanned
|
||||||
|
|
||||||
- .opencode/skills
|
- .opencode/skills
|
||||||
- .claude/skills
|
- .claude/skills
|
||||||
- /home/alex/.config/opencode/skills
|
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
@@ -20,12 +19,11 @@ Last updated: 2026-06-02
|
|||||||
|
|
||||||
| Skill | Trigger / description | Scope | Path |
|
| Skill | Trigger / description | Scope | Path |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
|
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/workspace/.opencode/skills/openspec-apply-change/SKILL.md` |
|
||||||
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
|
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/workspace/.opencode/skills/openspec-archive-change/SKILL.md` |
|
||||||
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
|
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/workspace/.opencode/skills/openspec-explore/SKILL.md` |
|
||||||
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
|
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/workspace/.opencode/skills/openspec-propose/SKILL.md` |
|
||||||
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-propose/SKILL.md` |
|
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/workspace/.claude/skills/sift-backlog/SKILL.md` |
|
||||||
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/home/alex/projects/headquarter/.claude/skills/sift-backlog/SKILL.md` |
|
|
||||||
|
|
||||||
## Loading protocol
|
## Loading protocol
|
||||||
|
|
||||||
|
|||||||
@@ -54,3 +54,5 @@ Thumbs.db
|
|||||||
.atl/
|
.atl/
|
||||||
.sisyphus/
|
.sisyphus/
|
||||||
.pi-lens/
|
.pi-lens/
|
||||||
|
minerv3/
|
||||||
|
.cache/
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"""Config profile API endpoints."""
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -12,10 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models import ConfigProfile, ConfigProfileInclude
|
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
||||||
from src.models.project import Project
|
|
||||||
from src.models import ToolType
|
|
||||||
from src.models import UserConfig
|
|
||||||
from src.schemas.config import (
|
from src.schemas.config import (
|
||||||
ConfigProfileCreate,
|
ConfigProfileCreate,
|
||||||
ConfigProfileIncludeUpdate,
|
ConfigProfileIncludeUpdate,
|
||||||
@@ -27,125 +21,27 @@ from src.schemas.config import (
|
|||||||
)
|
)
|
||||||
from src.services.config.config_profile_resolver import (
|
from src.services.config.config_profile_resolver import (
|
||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
check_include_cycle,
|
|
||||||
resolve_profile,
|
resolve_profile,
|
||||||
resolved_profile_to_dict,
|
resolved_profile_to_dict,
|
||||||
)
|
)
|
||||||
from src.utils.git_url_parser import parse_git_url
|
from src.services.config.crud_service import (
|
||||||
|
create_profile,
|
||||||
|
get_or_create_user_config,
|
||||||
|
get_profile_with_includes,
|
||||||
|
profile_to_response,
|
||||||
|
update_includes,
|
||||||
|
update_profile,
|
||||||
|
validate_default_profiles,
|
||||||
|
)
|
||||||
|
from src.services.config.resolver_service import (
|
||||||
|
resolve_default_profile,
|
||||||
|
validate_git_url,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
MAX_PROFILE_SIZE_MB = 10
|
|
||||||
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_profile_size(data: dict) -> int:
|
|
||||||
"""Calculate approximate serialized size of profile data."""
|
|
||||||
total = 0
|
|
||||||
for key, value in data.get("env_vars", {}).items():
|
|
||||||
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
|
||||||
for key, value in data.get("runtime_hints", {}).items():
|
|
||||||
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
|
||||||
for mount in data.get("mounts", []):
|
|
||||||
total += len(str(mount.get("target", "")).encode("utf-8"))
|
|
||||||
total += len(str(mount.get("mode", "")).encode("utf-8"))
|
|
||||||
for path, content in mount.get("files", {}).items():
|
|
||||||
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
|
||||||
for path, content in data.get("files", {}).items():
|
|
||||||
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_profile_with_includes(
|
|
||||||
session: AsyncSession, profile_id: uuid.UUID
|
|
||||||
) -> ConfigProfile | None:
|
|
||||||
"""Fetch a profile with includes eagerly loaded."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigProfile)
|
|
||||||
.where(ConfigProfile.id == profile_id)
|
|
||||||
.options(selectinload(ConfigProfile.includes))
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_access(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID | None = None,
|
|
||||||
tool_type_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Verify user has access to referenced project and tool type."""
|
|
||||||
if project_id is not None:
|
|
||||||
project = await session.get(Project, project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
|
||||||
)
|
|
||||||
# Add ownership check if needed; for now just verify existence
|
|
||||||
if tool_type_id is not None:
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
|
||||||
if tool_type is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_git_mounts(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
git_mounts: list[Any],
|
|
||||||
project_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Validate git mount URLs.
|
|
||||||
|
|
||||||
Simply checks that remote_url looks like a valid git URL.
|
|
||||||
Actual clone validation happens at instance startup time.
|
|
||||||
"""
|
|
||||||
for mount in git_mounts:
|
|
||||||
remote_url = mount.get("remote_url")
|
|
||||||
if not remote_url:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Git mount missing remote_url",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Invalid git URL: {remote_url}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_to_response(
|
|
||||||
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
|
|
||||||
) -> dict:
|
|
||||||
return {
|
|
||||||
"id": str(profile.id),
|
|
||||||
"user_id": str(profile.user_id),
|
|
||||||
"name": profile.name,
|
|
||||||
"description": profile.description,
|
|
||||||
"project_id": str(profile.project_id) if profile.project_id else None,
|
|
||||||
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
|
|
||||||
"env_vars": profile.env_vars or {},
|
|
||||||
"runtime_hints": profile.runtime_hints or {},
|
|
||||||
"mounts": profile.mounts or [],
|
|
||||||
"git_mounts": profile.git_mounts or [],
|
|
||||||
"files": profile.files or {},
|
|
||||||
"is_default": profile.is_default,
|
|
||||||
"includes": [
|
|
||||||
{
|
|
||||||
"id": str(inc.id),
|
|
||||||
"included_profile_id": str(inc.included_profile_id),
|
|
||||||
"order_index": inc.order_index,
|
|
||||||
}
|
|
||||||
for inc in (includes or profile.includes)
|
|
||||||
],
|
|
||||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
|
||||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ConfigProfileResponse])
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
async def list_config_profiles(
|
async def list_config_profiles(
|
||||||
@@ -165,26 +61,21 @@ async def list_config_profiles(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if project_id or tool_type_id:
|
if project_id or tool_type_id:
|
||||||
# Compatibility filter: include portable profiles and matching scoped profiles
|
from sqlalchemy import or_
|
||||||
|
|
||||||
project_uuid = uuid.UUID(project_id) if project_id else None
|
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||||
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||||
|
|
||||||
from sqlalchemy import or_
|
|
||||||
|
|
||||||
conditions: list = []
|
conditions: list = []
|
||||||
# Portable profiles (no project, no tool)
|
|
||||||
conditions.append(
|
conditions.append(
|
||||||
(ConfigProfile.project_id.is_(None))
|
(ConfigProfile.project_id.is_(None))
|
||||||
& (ConfigProfile.tool_type_id.is_(None))
|
& (ConfigProfile.tool_type_id.is_(None))
|
||||||
)
|
)
|
||||||
if project_uuid:
|
if project_uuid:
|
||||||
# Profiles matching this project (with or without tool)
|
|
||||||
conditions.append(ConfigProfile.project_id == project_uuid)
|
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||||
if tool_uuid:
|
if tool_uuid:
|
||||||
# Profiles matching this tool (with or without project)
|
|
||||||
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||||
if project_uuid and tool_uuid:
|
if project_uuid and tool_uuid:
|
||||||
# Exact match
|
|
||||||
conditions.append(
|
conditions.append(
|
||||||
(ConfigProfile.project_id == project_uuid)
|
(ConfigProfile.project_id == project_uuid)
|
||||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
@@ -194,7 +85,7 @@ async def list_config_profiles(
|
|||||||
|
|
||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
profiles = result.scalars().all()
|
profiles = result.scalars().all()
|
||||||
return [_profile_to_response(p) for p in profiles]
|
return [profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -206,69 +97,9 @@ async def create_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Create a new config profile."""
|
"""Create a new config profile."""
|
||||||
user_uuid = current_user_id
|
profile = await create_profile(session, current_user_id, data)
|
||||||
|
logger.debug("Created config profile %s for user %s", profile.id, current_user_id)
|
||||||
# Check for duplicate name
|
return profile_to_response(profile)
|
||||||
existing = await session.execute(
|
|
||||||
select(ConfigProfile)
|
|
||||||
.where(
|
|
||||||
ConfigProfile.user_id == user_uuid,
|
|
||||||
ConfigProfile.name == data.name,
|
|
||||||
)
|
|
||||||
.options(selectinload(ConfigProfile.includes))
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none() is not None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Check size
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
profile = ConfigProfile(
|
|
||||||
user_id=user_uuid,
|
|
||||||
name=data.name,
|
|
||||||
description=data.description,
|
|
||||||
project_id=project_uuid,
|
|
||||||
tool_type_id=tool_uuid,
|
|
||||||
env_vars=data.env_vars,
|
|
||||||
runtime_hints=data.runtime_hints,
|
|
||||||
mounts=[m.model_dump() for m in data.mounts],
|
|
||||||
git_mounts=[m.model_dump() for m in data.git_mounts],
|
|
||||||
files=data.files,
|
|
||||||
is_default=data.is_default,
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
.options(selectinload(ConfigProfile.includes))
|
|
||||||
)
|
|
||||||
profile = result.scalar_one()
|
|
||||||
|
|
||||||
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
|
||||||
return _profile_to_response(profile)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
@@ -278,7 +109,7 @@ async def get_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Get a config profile by ID."""
|
"""Get a config profile by ID."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -287,7 +118,7 @@ async def get_config_profile(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
return _profile_to_response(profile)
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
@@ -298,7 +129,7 @@ async def update_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Update a config profile."""
|
"""Update a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -308,78 +139,9 @@ async def update_config_profile(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
profile = await update_profile(session, profile, data)
|
||||||
|
|
||||||
# Handle name uniqueness
|
|
||||||
if "name" in update_data:
|
|
||||||
existing = await session.execute(
|
|
||||||
select(ConfigProfile).where(
|
|
||||||
ConfigProfile.user_id == profile.user_id,
|
|
||||||
ConfigProfile.name == update_data["name"],
|
|
||||||
ConfigProfile.id != profile.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none() is not None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
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"]
|
|
||||||
else (profile.project_id if "project_id" not in update_data else None)
|
|
||||||
)
|
|
||||||
tool_uuid = (
|
|
||||||
uuid.UUID(update_data["tool_type_id"])
|
|
||||||
if "tool_type_id" in update_data and update_data["tool_type_id"]
|
|
||||||
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
|
||||||
)
|
|
||||||
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(
|
|
||||||
session, profile.user_id, git_mounts_data, project_uuid
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check size
|
|
||||||
current_data = _profile_to_response(profile)
|
|
||||||
merged = {**current_data, **update_data}
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
elif field_name == "mounts" and value is not None:
|
|
||||||
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
|
||||||
elif field_name == "git_mounts" and value is not None:
|
|
||||||
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
|
||||||
setattr(profile, field_name, value)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Re-fetch with includes to avoid lazy loading issues
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigProfile)
|
|
||||||
.where(ConfigProfile.id == profile.id)
|
|
||||||
.options(selectinload(ConfigProfile.includes))
|
|
||||||
)
|
|
||||||
profile = result.scalar_one()
|
|
||||||
|
|
||||||
logger.debug("Updated config profile %s", profile.id)
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
return _profile_to_response(profile)
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -389,7 +151,7 @@ async def delete_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Delete a config profile."""
|
"""Delete a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -407,14 +169,14 @@ async def delete_config_profile(
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||||
async def update_profile_includes(
|
async def update_profile_includes_endpoint(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
data: ConfigProfileIncludeUpdate,
|
data: ConfigProfileIncludeUpdate,
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Update the ordered includes for a config profile."""
|
"""Update the ordered includes for a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -424,69 +186,8 @@ async def update_profile_includes(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate all included profiles exist and belong to the user
|
|
||||||
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
for inc_uuid in included_uuids:
|
profile = await update_includes(session, profile, included_uuids, current_user_id)
|
||||||
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
|
||||||
if inc_profile is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Included profile not found: {inc_uuid}",
|
|
||||||
)
|
|
||||||
if inc_profile.user_id != current_user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"Not authorized to include profile: {inc_uuid}",
|
|
||||||
)
|
|
||||||
if inc_uuid == profile.id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
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:
|
|
||||||
break
|
|
||||||
|
|
||||||
if cycle is not None:
|
|
||||||
cycle_str = " -> ".join(str(c) for c in cycle)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Include cycle detected: {cycle_str}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove existing includes
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigProfileInclude).where(
|
|
||||||
ConfigProfileInclude.profile_id == profile.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for existing in result.scalars().all():
|
|
||||||
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,
|
|
||||||
included_profile_id=inc_uuid,
|
|
||||||
order_index=order_index,
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
profile = result.scalar_one()
|
|
||||||
|
|
||||||
inc_result = await session.execute(
|
inc_result = await session.execute(
|
||||||
select(ConfigProfileInclude).where(
|
select(ConfigProfileInclude).where(
|
||||||
@@ -496,7 +197,7 @@ async def update_profile_includes(
|
|||||||
direct_includes = inc_result.scalars().all()
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
logger.debug("Updated includes for config profile %s", profile.id)
|
logger.debug("Updated includes for config profile %s", profile.id)
|
||||||
return _profile_to_response(profile, list(direct_includes))
|
return profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}/preview")
|
@router.get("/{profile_id}/preview")
|
||||||
@@ -506,7 +207,7 @@ async def preview_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Preview the resolved output of a config profile."""
|
"""Preview the resolved output of a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -528,120 +229,19 @@ async def preview_config_profile(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/defaults/resolve")
|
@router.get("/defaults/resolve")
|
||||||
async def resolve_default_profile(
|
async def resolve_default_profile_endpoint(
|
||||||
project_id: str = Query(..., description="Project ID"),
|
project_id: str = Query(..., description="Project ID"),
|
||||||
tool_type_id: str = Query(..., description="Tool type ID"),
|
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Resolve the default config profile for a project/tool combination.
|
"""Resolve the default config profile for a project/tool combination."""
|
||||||
|
return await resolve_default_profile(
|
||||||
Selects by specificity:
|
session,
|
||||||
1. project+tool explicit default
|
current_user_id,
|
||||||
2. project explicit default
|
uuid.UUID(project_id),
|
||||||
3. tool explicit default
|
uuid.UUID(tool_type_id),
|
||||||
4. global/user explicit default
|
|
||||||
5. first created compatible profile
|
|
||||||
6. none (returns null)
|
|
||||||
"""
|
|
||||||
user_uuid = current_user_id
|
|
||||||
project_uuid = uuid.UUID(project_id)
|
|
||||||
tool_uuid = uuid.UUID(tool_type_id)
|
|
||||||
|
|
||||||
# Fetch all compatible profiles ordered by created_at
|
|
||||||
query = (
|
|
||||||
select(ConfigProfile)
|
|
||||||
.where(ConfigProfile.user_id == user_uuid)
|
|
||||||
.where(
|
|
||||||
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
|
||||||
| (ConfigProfile.project_id == project_uuid)
|
|
||||||
| (ConfigProfile.tool_type_id == tool_uuid)
|
|
||||||
| (
|
|
||||||
(ConfigProfile.project_id == project_uuid)
|
|
||||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by(ConfigProfile.created_at)
|
|
||||||
)
|
)
|
||||||
result = await session.execute(query)
|
|
||||||
profiles = result.scalars().all()
|
|
||||||
|
|
||||||
if not profiles:
|
|
||||||
return {"profile_id": None, "profile_name": None}
|
|
||||||
|
|
||||||
# Check explicit defaults by specificity
|
|
||||||
explicit_defaults = [p for p in profiles if p.is_default]
|
|
||||||
|
|
||||||
# Most specific: project+tool
|
|
||||||
for p in explicit_defaults:
|
|
||||||
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
|
|
||||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
|
||||||
|
|
||||||
# Next: project only
|
|
||||||
for p in explicit_defaults:
|
|
||||||
if p.project_id == project_uuid and p.tool_type_id is None:
|
|
||||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
|
||||||
|
|
||||||
# Next: tool only
|
|
||||||
for p in explicit_defaults:
|
|
||||||
if p.project_id is None and p.tool_type_id == tool_uuid:
|
|
||||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
|
||||||
|
|
||||||
# Next: global/user (no project, no tool)
|
|
||||||
for p in explicit_defaults:
|
|
||||||
if p.project_id is None and p.tool_type_id is None:
|
|
||||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
|
||||||
|
|
||||||
# Fall back to first created compatible profile
|
|
||||||
first = profiles[0]
|
|
||||||
return {"profile_id": str(first.id), "profile_name": first.name}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Default profile management
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_user_config(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
) -> UserConfig:
|
|
||||||
"""Get existing user config or create a new one."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
|
||||||
)
|
|
||||||
user_config = result.scalar_one_or_none()
|
|
||||||
if user_config is None:
|
|
||||||
user_config = UserConfig(user_id=user_id, config={})
|
|
||||||
session.add(user_config)
|
|
||||||
return user_config
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_default_profiles(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
default_profiles: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
"""Validate that all profile IDs in default_profiles belong to the user."""
|
|
||||||
for tool_type_id, profile_id_str in default_profiles.items():
|
|
||||||
try:
|
|
||||||
profile_uuid = uuid.UUID(profile_id_str)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
|
|
||||||
)
|
|
||||||
profile = await session.get(ConfigProfile, profile_uuid)
|
|
||||||
if profile is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Profile not found: {profile_id_str}",
|
|
||||||
)
|
|
||||||
if profile.user_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"Profile does not belong to user: {profile_id_str}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/defaults")
|
@router.get("/defaults")
|
||||||
@@ -664,8 +264,8 @@ async def set_default_profiles_endpoint(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Set default profile mappings for the current user."""
|
"""Set default profile mappings for the current user."""
|
||||||
await _validate_default_profiles(session, user_id, data.default_profiles)
|
await validate_default_profiles(session, user_id, data.default_profiles)
|
||||||
user_config = await _get_or_create_user_config(session, user_id)
|
user_config = await get_or_create_user_config(session, user_id)
|
||||||
user_config.config = {
|
user_config.config = {
|
||||||
**user_config.config,
|
**user_config.config,
|
||||||
"default_profiles": data.default_profiles,
|
"default_profiles": data.default_profiles,
|
||||||
@@ -691,152 +291,10 @@ async def get_default_profile_for_tool_type_endpoint(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||||
async def validate_git_url(
|
async def validate_git_url_endpoint(
|
||||||
data: ValidateGitUrlRequest,
|
data: ValidateGitUrlRequest,
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ValidateGitUrlResponse:
|
) -> ValidateGitUrlResponse:
|
||||||
"""Validate a git remote URL and list available branches.
|
"""Validate a git remote URL and list available branches."""
|
||||||
|
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
|
||||||
Parses the URL, suggests corrections for browser URLs, and runs
|
|
||||||
git ls-remote to verify reachability and enumerate branches.
|
|
||||||
"""
|
|
||||||
parse_result = parse_git_url(data.url)
|
|
||||||
original_url = data.url.strip()
|
|
||||||
url_to_check = parse_result.get("base_url") or original_url
|
|
||||||
|
|
||||||
if not url_to_check:
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error=parse_result.get("message", "Invalid URL"),
|
|
||||||
error_code=parse_result.get("error_code", "INVALID_URL"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# If the URL needed parsing, return suggestion without checking remote
|
|
||||||
if parse_result.get("needs_parsing") and url_to_check != original_url:
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
suggested_url=url_to_check,
|
|
||||||
error=parse_result.get("message"),
|
|
||||||
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Optional SSH key for private repos
|
|
||||||
env = None
|
|
||||||
key_path = None
|
|
||||||
if data.ssh_key_id:
|
|
||||||
from src.models import SSHKey
|
|
||||||
from src.services.shared.ssh_keys import _get_fernet
|
|
||||||
|
|
||||||
try:
|
|
||||||
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
|
|
||||||
except ValueError:
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error="Invalid SSH key ID format",
|
|
||||||
error_code="INVALID_SSH_KEY",
|
|
||||||
)
|
|
||||||
|
|
||||||
ssh_key = await session.get(SSHKey, ssh_key_uuid)
|
|
||||||
if ssh_key is None or ssh_key.user_id != current_user_id:
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error="SSH key not found or not authorized",
|
|
||||||
error_code="SSH_KEY_NOT_FOUND",
|
|
||||||
)
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
fernet = _get_fernet()
|
|
||||||
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
|
||||||
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
|
||||||
try:
|
|
||||||
os.write(fd, private_key.encode())
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
os.chmod(key_path, 0o600)
|
|
||||||
env = {
|
|
||||||
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "ls-remote", "--heads", url_to_check],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30,
|
|
||||||
env={**os.environ, **env} if env else None,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
if key_path and os.path.exists(key_path):
|
|
||||||
os.unlink(key_path)
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error="Remote repository check timed out",
|
|
||||||
error_code="TIMEOUT",
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
if key_path and os.path.exists(key_path):
|
|
||||||
os.unlink(key_path)
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error="git command not found on server",
|
|
||||||
error_code="GIT_NOT_FOUND",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if key_path and os.path.exists(key_path):
|
|
||||||
os.unlink(key_path)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
stderr = result.stderr.strip()
|
|
||||||
if (
|
|
||||||
"could not resolve" in stderr.lower()
|
|
||||||
or "unable to access" in stderr.lower()
|
|
||||||
):
|
|
||||||
error_msg = "Could not reach repository. Check the URL and network access."
|
|
||||||
error_code = "UNREACHABLE"
|
|
||||||
elif (
|
|
||||||
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
|
|
||||||
):
|
|
||||||
error_msg = (
|
|
||||||
"Authentication failed. Provide an SSH key for private repositories."
|
|
||||||
)
|
|
||||||
error_code = "AUTH_FAILED"
|
|
||||||
else:
|
|
||||||
error_msg = f"Repository not accessible: {stderr[:200]}"
|
|
||||||
error_code = "REMOTE_ERROR"
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error=error_msg,
|
|
||||||
error_code=error_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse branches from ls-remote output
|
|
||||||
branches: list[str] = []
|
|
||||||
default_branch = "main"
|
|
||||||
for line in result.stdout.strip().split("\n"):
|
|
||||||
if not line.strip():
|
|
||||||
continue
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) == 2:
|
|
||||||
ref = parts[1]
|
|
||||||
# refs/heads/branch-name
|
|
||||||
if ref.startswith("refs/heads/"):
|
|
||||||
branch_name = ref[len("refs/heads/") :]
|
|
||||||
branches.append(branch_name)
|
|
||||||
if branch_name in ("main", "master"):
|
|
||||||
default_branch = branch_name
|
|
||||||
|
|
||||||
if not branches:
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=False,
|
|
||||||
error="No branches found in remote repository",
|
|
||||||
error_code="NO_BRANCHES",
|
|
||||||
)
|
|
||||||
|
|
||||||
return ValidateGitUrlResponse(
|
|
||||||
valid=True,
|
|
||||||
suggested_url=url_to_check if url_to_check != original_url else None,
|
|
||||||
branches=branches,
|
|
||||||
default_branch=default_branch,
|
|
||||||
)
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,13 @@ async def get_user_sessions(
|
|||||||
repo = await session.get(GitRepository, instance.repository_id)
|
repo = await session.get(GitRepository, instance.repository_id)
|
||||||
project = await session.get(Project, instance.project_id)
|
project = await session.get(Project, instance.project_id)
|
||||||
|
|
||||||
|
workspace_name = None
|
||||||
|
if instance.workspace_id:
|
||||||
|
from src.models import Workspace as WorkspaceModel
|
||||||
|
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
||||||
|
if workspace:
|
||||||
|
workspace_name = workspace.name
|
||||||
|
|
||||||
sessions.append(
|
sessions.append(
|
||||||
{
|
{
|
||||||
"id": str(instance.id),
|
"id": str(instance.id),
|
||||||
@@ -64,6 +71,7 @@ async def get_user_sessions(
|
|||||||
"repository_id": str(instance.repository_id),
|
"repository_id": str(instance.repository_id),
|
||||||
"project_name": project.name if project else "unknown",
|
"project_name": project.name if project else "unknown",
|
||||||
"project_id": str(instance.project_id),
|
"project_id": str(instance.project_id),
|
||||||
|
"workspace_name": workspace_name,
|
||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
"clone_mode": instance.clone_mode,
|
"clone_mode": instance.clone_mode,
|
||||||
|
|||||||
+127
-2491
File diff suppressed because it is too large
Load Diff
@@ -45,3 +45,91 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
|
|
||||||
class UpdateSSHKeyRequest(BaseModel):
|
class UpdateSSHKeyRequest(BaseModel):
|
||||||
ssh_key_id: str | None = None
|
ssh_key_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileListResponse(BaseModel):
|
||||||
|
path: str
|
||||||
|
branch: str
|
||||||
|
entries: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class FileContentResponse(BaseModel):
|
||||||
|
path: str
|
||||||
|
branch: str
|
||||||
|
content: str
|
||||||
|
size: int
|
||||||
|
encoding: str
|
||||||
|
language: str | None
|
||||||
|
is_binary: bool
|
||||||
|
last_commit: dict | None
|
||||||
|
|
||||||
|
|
||||||
|
class BranchesResponse(BaseModel):
|
||||||
|
branches: list[dict]
|
||||||
|
default_branch: str
|
||||||
|
|
||||||
|
|
||||||
|
class FileUpdateRequest(BaseModel):
|
||||||
|
path: str
|
||||||
|
branch: str
|
||||||
|
content: str
|
||||||
|
commit_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class FileUpdateResponse(BaseModel):
|
||||||
|
commit_hash: str
|
||||||
|
message: str
|
||||||
|
branch: str
|
||||||
|
|
||||||
|
|
||||||
|
class StatusResponse(BaseModel):
|
||||||
|
branch: str
|
||||||
|
modified: list[str]
|
||||||
|
added: list[str]
|
||||||
|
deleted: list[str]
|
||||||
|
untracked: list[str]
|
||||||
|
renamed: list[str]
|
||||||
|
ahead: int
|
||||||
|
behind: int
|
||||||
|
|
||||||
|
|
||||||
|
class BranchCreateRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
base_branch: str = "HEAD"
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutRequest(BaseModel):
|
||||||
|
branch: str
|
||||||
|
|
||||||
|
|
||||||
|
class CommitRequest(BaseModel):
|
||||||
|
message: str
|
||||||
|
files: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommitResponse(BaseModel):
|
||||||
|
commit_hash: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class FetchResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class PullResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class PushResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class MergeRequest(BaseModel):
|
||||||
|
source_branch: str
|
||||||
|
target_branch: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MergeResponse(BaseModel):
|
||||||
|
commit_hash: str
|
||||||
|
message: str
|
||||||
|
|||||||
@@ -172,10 +172,19 @@ def compile_dockerfile(manifest: dict) -> str:
|
|||||||
lines.append(f"ENV HOME={home}")
|
lines.append(f"ENV HOME={home}")
|
||||||
lines.append(f"ENV USER={name}")
|
lines.append(f"ENV USER={name}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
# Ensure home directory exists and is writable by the user
|
# Ensure home directory exists and is writable by the user.
|
||||||
|
# Recursively chown so any files copied from /etc/skel by useradd -m
|
||||||
|
# (e.g. .bashrc, .config) are owned by the container user.
|
||||||
lines.append(
|
lines.append(
|
||||||
f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}"
|
f"RUN mkdir -p {home} && chown -R {name}:{name} {home} && chmod 755 {home}"
|
||||||
)
|
)
|
||||||
|
# Pre-create common config directories so apps like ranger can write
|
||||||
|
# their configs on first run without permission errors.
|
||||||
|
common_dirs = [".config", ".local/share", ".cache"]
|
||||||
|
for d in common_dirs:
|
||||||
|
lines.append(
|
||||||
|
f"RUN mkdir -p {home}/{d} && chown -R {name}:{name} {home}/{d}"
|
||||||
|
)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Configure passwordless sudo so startup scripts can fix permissions
|
# Configure passwordless sudo so startup scripts can fix permissions
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""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}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
data: Any,
|
||||||
|
) -> ConfigProfile:
|
||||||
|
"""Create a new config profile after validation."""
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(
|
||||||
|
ConfigProfile.user_id == user_id,
|
||||||
|
ConfigProfile.name == data.name,
|
||||||
|
)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Profile with name '{data.name}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
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_id, project_uuid, tool_uuid)
|
||||||
|
|
||||||
|
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_id, git_mounts_data, project_uuid)
|
||||||
|
|
||||||
|
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="Profile size exceeds 10MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = ConfigProfile(
|
||||||
|
user_id=user_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
project_id=project_uuid,
|
||||||
|
tool_type_id=tool_uuid,
|
||||||
|
env_vars=data.env_vars,
|
||||||
|
runtime_hints=data.runtime_hints,
|
||||||
|
mounts=[m.model_dump() for m in data.mounts],
|
||||||
|
git_mounts=[m.model_dump() for m in data.git_mounts],
|
||||||
|
files=data.files,
|
||||||
|
is_default=data.is_default,
|
||||||
|
)
|
||||||
|
session.add(profile)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile.id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile: ConfigProfile,
|
||||||
|
data: Any,
|
||||||
|
) -> ConfigProfile:
|
||||||
|
"""Update a config profile after validation."""
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if "name" in update_data:
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == profile.user_id,
|
||||||
|
ConfigProfile.name == update_data["name"],
|
||||||
|
ConfigProfile.id != profile.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Profile with name '{update_data['name']}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
project_uuid = (
|
||||||
|
uuid.UUID(update_data["project_id"])
|
||||||
|
if "project_id" in update_data and update_data["project_id"]
|
||||||
|
else (profile.project_id if "project_id" not in update_data else None)
|
||||||
|
)
|
||||||
|
tool_uuid = (
|
||||||
|
uuid.UUID(update_data["tool_type_id"])
|
||||||
|
if "tool_type_id" in update_data and update_data["tool_type_id"]
|
||||||
|
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
||||||
|
)
|
||||||
|
await check_access(session, profile.user_id, project_uuid, tool_uuid)
|
||||||
|
|
||||||
|
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(
|
||||||
|
session, profile.user_id, git_mounts_data, project_uuid
|
||||||
|
)
|
||||||
|
|
||||||
|
current_data = profile_to_response(profile)
|
||||||
|
merged = {**current_data, **update_data}
|
||||||
|
size = calculate_profile_size(merged)
|
||||||
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail="Profile size exceeds 10MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
elif field_name == "mounts" and value is not None:
|
||||||
|
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||||
|
elif field_name == "git_mounts" and value is not None:
|
||||||
|
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||||
|
setattr(profile, field_name, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile.id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_includes(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile: ConfigProfile,
|
||||||
|
included_ids: list[uuid.UUID],
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> ConfigProfile:
|
||||||
|
"""Replace profile includes after cycle check."""
|
||||||
|
for inc_uuid in included_ids:
|
||||||
|
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||||
|
if inc_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Included profile not found: {inc_uuid}",
|
||||||
|
)
|
||||||
|
if inc_profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Not authorized to include profile: {inc_uuid}",
|
||||||
|
)
|
||||||
|
if inc_uuid == profile.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Profile cannot include itself",
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.services.config.config_profile_resolver import check_include_cycle
|
||||||
|
|
||||||
|
cycle = await check_include_cycle(session, profile.id, None)
|
||||||
|
if cycle is None and included_ids:
|
||||||
|
for inc_uuid in included_ids:
|
||||||
|
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
||||||
|
if cycle is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if cycle is not None:
|
||||||
|
cycle_str = " -> ".join(str(c) for c in cycle)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Include cycle detected: {cycle_str}",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfileInclude).where(
|
||||||
|
ConfigProfileInclude.profile_id == profile.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for existing in result.scalars().all():
|
||||||
|
await session.delete(existing)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
for order_index, inc_uuid in enumerate(included_ids):
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
profile_id=profile.id,
|
||||||
|
included_profile_id=inc_uuid,
|
||||||
|
order_index=order_index,
|
||||||
|
)
|
||||||
|
session.add(include)
|
||||||
|
await session.flush()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
@@ -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,213 @@
|
|||||||
|
"""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}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_remote_branches(remote_url: str, ssh_key: SSHKey | None = None) -> tuple[list[str], str]:
|
||||||
|
"""List branches from a remote repository via ls-remote.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (branch_names, default_branch).
|
||||||
|
"""
|
||||||
|
ssh_result = prepare_ssh_env(ssh_key)
|
||||||
|
env, key_path = ssh_result if ssh_result else (None, None)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", "--heads", remote_url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning("ls-remote returned %d: %s", result.returncode, result.stderr)
|
||||||
|
raise RuntimeError(f"ls-remote failed: {result.stderr}")
|
||||||
|
branches = []
|
||||||
|
default_branch = "main"
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split("\t")
|
||||||
|
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
|
||||||
|
return branches, default_branch
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("ls-remote timed out for %s", remote_url)
|
||||||
|
raise RuntimeError("ls-remote timed out")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("ls-remote failed for %s: %s", remote_url, str(e))
|
||||||
|
raise RuntimeError(f"ls-remote failed: {e}")
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
@@ -90,8 +90,16 @@ class HealthMonitor:
|
|||||||
instance: ToolInstance,
|
instance: ToolInstance,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check a single instance and handle state transitions."""
|
"""Check a single instance and handle state transitions."""
|
||||||
|
# Skip instances that have never been assigned a container.
|
||||||
|
if not instance.container_id:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping health check for instance %s: no container_id",
|
||||||
|
instance.id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
container_info = get_container_status(instance.container_id or "")
|
container_info = get_container_status(instance.container_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Health check failed for instance %s",
|
"Health check failed for instance %s",
|
||||||
@@ -135,22 +143,70 @@ class HealthMonitor:
|
|||||||
previous = self._last_known_state.get(instance.id)
|
previous = self._last_known_state.get(instance.id)
|
||||||
|
|
||||||
# Determine new status
|
# Determine new status
|
||||||
new_status = self._derive_status(snapshot)
|
new_status = self._derive_status(
|
||||||
|
snapshot,
|
||||||
|
previous,
|
||||||
|
instance.status,
|
||||||
|
)
|
||||||
|
|
||||||
# If first check or state changed
|
# If first check or snapshot changed
|
||||||
if previous is None or not self._snapshots_equal(previous, snapshot):
|
if previous is None:
|
||||||
await self._handle_state_change(
|
# Monitor restart / first time seeing this instance.
|
||||||
session, instance, previous, snapshot, new_status
|
# Only act if the derived status is different from the DB status.
|
||||||
)
|
# This prevents duplicate notifications after monitor restarts.
|
||||||
self._last_known_state[instance.id] = snapshot
|
if new_status == instance.status:
|
||||||
|
self._last_known_state[instance.id] = snapshot
|
||||||
|
return
|
||||||
|
elif self._snapshots_equal(previous, snapshot):
|
||||||
|
# Nothing changed since last poll — skip entirely.
|
||||||
|
return
|
||||||
|
|
||||||
def _derive_status(self, snapshot: HealthSnapshot) -> str:
|
await self._handle_state_change(
|
||||||
"""Derive instance status from health snapshot."""
|
session, instance, previous, snapshot, new_status
|
||||||
if snapshot.container_status != "running":
|
)
|
||||||
|
self._last_known_state[instance.id] = snapshot
|
||||||
|
|
||||||
|
def _derive_status(
|
||||||
|
self,
|
||||||
|
snapshot: HealthSnapshot,
|
||||||
|
previous: HealthSnapshot | None,
|
||||||
|
current_status: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""Derive instance status from health snapshot.
|
||||||
|
|
||||||
|
Treats missing containers as an error only when the container was
|
||||||
|
previously known to be running. This avoids false "container failed"
|
||||||
|
alerts for instances that are still starting or have no container yet.
|
||||||
|
"""
|
||||||
|
if snapshot.container_status == "not_found":
|
||||||
|
# Still starting — container may not exist yet.
|
||||||
|
if current_status == "starting":
|
||||||
|
return "starting"
|
||||||
|
# Container disappeared while it was supposed to be running.
|
||||||
|
if current_status == "running":
|
||||||
|
return "error"
|
||||||
|
# If we have previous memory and the container was running,
|
||||||
|
# mark as error (handles monitor restart edge case).
|
||||||
|
if previous is not None and previous.container_status == "running":
|
||||||
|
return "error"
|
||||||
|
# Fall back to current status to avoid spurious errors.
|
||||||
|
return current_status or "error"
|
||||||
|
|
||||||
|
if snapshot.container_status in ("exited", "dead"):
|
||||||
return "error"
|
return "error"
|
||||||
if snapshot.tunnel_healthy is False:
|
|
||||||
return "unhealthy"
|
if snapshot.container_status == "running":
|
||||||
return "running"
|
if snapshot.tunnel_healthy is False:
|
||||||
|
return "unhealthy"
|
||||||
|
return "running"
|
||||||
|
|
||||||
|
# Transient states (created, restarting) — preserve current status
|
||||||
|
# instead of treating them as an error. The next poll will resolve.
|
||||||
|
if snapshot.container_status in ("created", "restarting"):
|
||||||
|
return current_status or "starting"
|
||||||
|
|
||||||
|
# Unknown/unexpected state (paused, etc.)
|
||||||
|
return "error"
|
||||||
|
|
||||||
def _snapshots_equal(self, a: HealthSnapshot, b: HealthSnapshot) -> bool:
|
def _snapshots_equal(self, a: HealthSnapshot, b: HealthSnapshot) -> bool:
|
||||||
"""Compare two snapshots for equality."""
|
"""Compare two snapshots for equality."""
|
||||||
@@ -206,7 +262,10 @@ class HealthMonitor:
|
|||||||
message += f" (exit code: {snapshot.exit_code})"
|
message += f" (exit code: {snapshot.exit_code})"
|
||||||
else:
|
else:
|
||||||
event_type = "instance.health_changed"
|
event_type = "instance.health_changed"
|
||||||
message = f"Container is now {new_status}"
|
if new_status == "unhealthy":
|
||||||
|
message = "Container tunnel is unreachable"
|
||||||
|
else:
|
||||||
|
message = f"Container is now {new_status}"
|
||||||
|
|
||||||
payload: InstanceEventPayload = {
|
payload: InstanceEventPayload = {
|
||||||
"event": event_type,
|
"event": event_type,
|
||||||
@@ -223,6 +282,16 @@ class HealthMonitor:
|
|||||||
# Create notification for instance owner (fire-and-forget)
|
# Create notification for instance owner (fire-and-forget)
|
||||||
# Only send warnings and errors; skip "recovered" info notifications.
|
# Only send warnings and errors; skip "recovered" info notifications.
|
||||||
if new_status == "error":
|
if new_status == "error":
|
||||||
|
# Skip duplicate error notifications if already in error state.
|
||||||
|
if previous_status == "error":
|
||||||
|
return
|
||||||
|
# Skip "not_found" errors for containers that were never running
|
||||||
|
# (e.g. still starting, or intentionally stopped/deleted).
|
||||||
|
if (
|
||||||
|
snapshot.container_status == "not_found"
|
||||||
|
and (previous is None or previous.container_status != "running")
|
||||||
|
):
|
||||||
|
return
|
||||||
category = "instance"
|
category = "instance"
|
||||||
severity = "error"
|
severity = "error"
|
||||||
title = "Container failed"
|
title = "Container failed"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,364 +2,37 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.services.manifest_compiler import (
|
from src.services.build.manifest_compiler import compile_dockerfile
|
||||||
compile_compose,
|
|
||||||
compile_dockerfile,
|
|
||||||
compile_entrypoint,
|
|
||||||
compute_image_tag,
|
|
||||||
deep_merge,
|
|
||||||
get_manifest_home_dir,
|
|
||||||
merge_with_config,
|
|
||||||
resolve_base,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveBase:
|
@pytest.mark.unit
|
||||||
"""Tests for resolve_base."""
|
def test_compile_dockerfile_creates_config_dirs_for_user() -> None:
|
||||||
|
"""The Dockerfile must pre-create ~/.config and chown it to the container user."""
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||||
|
}
|
||||||
|
|
||||||
def test_returns_manifest_unchanged_when_no_base(self) -> None:
|
dockerfile = compile_dockerfile(manifest)
|
||||||
manifest = {"name": "test", "base_image": "ubuntu:24.04"}
|
|
||||||
result = resolve_base(manifest)
|
assert "groupadd -g 1000 dev" in dockerfile
|
||||||
assert result["name"] == "test"
|
assert "useradd -u 1000 -g 1000 -m -s /bin/bash dev" in dockerfile
|
||||||
assert "base_definition_id" not in result
|
assert "mkdir -p /home/dev && chown -R dev:dev /home/dev" in dockerfile
|
||||||
|
assert "mkdir -p /home/dev/.config && chown -R dev:dev /home/dev/.config" in dockerfile
|
||||||
|
assert "mkdir -p /home/dev/.local/share && chown -R dev:dev /home/dev/.local/share" in dockerfile
|
||||||
|
assert "mkdir -p /home/dev/.cache && chown -R dev:dev /home/dev/.cache" in dockerfile
|
||||||
|
|
||||||
|
|
||||||
class TestDeepMerge:
|
@pytest.mark.unit
|
||||||
"""Tests for deep_merge."""
|
def test_compile_dockerfile_no_user_does_not_create_home() -> None:
|
||||||
|
"""Without a user config, no home/user setup should be emitted."""
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
}
|
||||||
|
|
||||||
def test_packages_are_unioned(self) -> None:
|
dockerfile = compile_dockerfile(manifest)
|
||||||
base = {"packages": {"apt": ["curl", "git"]}}
|
|
||||||
override = {"packages": {"apt": ["neovim"]}}
|
|
||||||
result = deep_merge(base, override)
|
|
||||||
assert result["packages"]["apt"] == ["curl", "git", "neovim"]
|
|
||||||
|
|
||||||
def test_node_version_overrides(self) -> None:
|
assert "useradd" not in dockerfile
|
||||||
base = {"packages": {"node": {"version": "18"}}}
|
assert "/home/" not in dockerfile
|
||||||
override = {"packages": {"node": {"version": "20"}}}
|
|
||||||
result = deep_merge(base, override)
|
|
||||||
assert result["packages"]["node"]["version"] == "20"
|
|
||||||
|
|
||||||
def test_env_is_merged_with_override_winning(self) -> None:
|
|
||||||
base = {"env": {"FOO": "base", "BAR": "base"}}
|
|
||||||
override = {"env": {"FOO": "override"}}
|
|
||||||
result = deep_merge(base, override)
|
|
||||||
assert result["env"]["FOO"] == "override"
|
|
||||||
assert result["env"]["BAR"] == "base"
|
|
||||||
|
|
||||||
def test_build_scripts_are_concatenated(self) -> None:
|
|
||||||
base = {"scripts": {"build": ["echo base"]}}
|
|
||||||
override = {"scripts": {"build": ["echo override"]}}
|
|
||||||
result = deep_merge(base, override)
|
|
||||||
assert result["scripts"]["build"] == ["echo base", "echo override"]
|
|
||||||
|
|
||||||
def test_mounts_are_concatenated(self) -> None:
|
|
||||||
base = {"mounts": [{"name": "base-mount", "target": "/base"}]}
|
|
||||||
override = {"mounts": [{"name": "tool-mount", "target": "/tool"}]}
|
|
||||||
result = deep_merge(base, override)
|
|
||||||
assert len(result["mounts"]) == 2
|
|
||||||
|
|
||||||
def test_user_is_overridden_entirely(self) -> None:
|
|
||||||
base = {"user": {"name": "base", "uid": 1000}}
|
|
||||||
override = {"user": {"name": "tool", "uid": 1001}}
|
|
||||||
result = deep_merge(base, override)
|
|
||||||
assert result["user"]["name"] == "tool"
|
|
||||||
assert result["user"]["uid"] == 1001
|
|
||||||
|
|
||||||
|
|
||||||
class TestCompileDockerfile:
|
|
||||||
"""Tests for compile_dockerfile."""
|
|
||||||
|
|
||||||
def test_includes_from(self) -> None:
|
|
||||||
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert "FROM ubuntu:24.04" in df
|
|
||||||
|
|
||||||
def test_installs_apt_packages(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"base_image": "ubuntu:24.04",
|
|
||||||
"name": "test",
|
|
||||||
"packages": {"apt": ["curl", "git"]},
|
|
||||||
}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert "apt-get install -y" in df
|
|
||||||
assert "curl" in df
|
|
||||||
assert "git" in df
|
|
||||||
assert "rm -rf /var/lib/apt/lists/*" in df
|
|
||||||
|
|
||||||
def test_installs_node(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"base_image": "ubuntu:24.04",
|
|
||||||
"name": "test",
|
|
||||||
"packages": {"node": {"version": "20"}},
|
|
||||||
}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert "nodesource.com/setup_20.x" in df
|
|
||||||
|
|
||||||
def test_installs_npm_global(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"base_image": "ubuntu:24.04",
|
|
||||||
"name": "test",
|
|
||||||
"packages": {"npm_global": ["@scope/pkg"]},
|
|
||||||
}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert "npm install -g @scope/pkg" in df
|
|
||||||
|
|
||||||
def test_creates_user(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"base_image": "ubuntu:24.04",
|
|
||||||
"name": "test",
|
|
||||||
"user": {"name": "dev", "uid": 1001, "gid": 1001},
|
|
||||||
}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert "groupadd -g 1001 dev" in df
|
|
||||||
assert "useradd -u 1001 -g 1001" in df
|
|
||||||
assert "USER dev" in df
|
|
||||||
|
|
||||||
def test_build_scripts_as_run_commands(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"base_image": "ubuntu:24.04",
|
|
||||||
"name": "test",
|
|
||||||
"scripts": {"build": ["echo hello", "echo world"]},
|
|
||||||
}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert "RUN echo hello" in df
|
|
||||||
assert "RUN echo world" in df
|
|
||||||
|
|
||||||
def test_creates_mount_directories(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"base_image": "ubuntu:24.04",
|
|
||||||
"name": "test",
|
|
||||||
"user": {"name": "dev", "uid": 1001, "gid": 1001},
|
|
||||||
"mounts": [
|
|
||||||
{"name": "ws", "target": "/workspace"},
|
|
||||||
{"name": "cfg", "target": "/config"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert "mkdir -p /workspace /config" in df
|
|
||||||
assert "chown -R dev:dev /workspace /config" in df
|
|
||||||
|
|
||||||
def test_entrypoint_for_startup_scripts(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"base_image": "ubuntu:24.04",
|
|
||||||
"name": "test",
|
|
||||||
"scripts": {"startup": ["echo start"]},
|
|
||||||
}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert 'ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]' in df
|
|
||||||
|
|
||||||
def test_cmd_from_runtime(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"base_image": "ubuntu:24.04",
|
|
||||||
"name": "test",
|
|
||||||
"runtime": {"command": ["/bin/bash", "-il"]},
|
|
||||||
}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert 'CMD ["/bin/bash", "-il"]' in df
|
|
||||||
|
|
||||||
def test_default_cmd_when_no_runtime(self) -> None:
|
|
||||||
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert 'CMD ["/bin/bash"]' in df
|
|
||||||
|
|
||||||
def test_sets_home_env_for_user(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"base_image": "ubuntu:24.04",
|
|
||||||
"name": "test",
|
|
||||||
"user": {"name": "dev", "uid": 1001, "gid": 1001},
|
|
||||||
}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert "ENV HOME=/home/dev" in df
|
|
||||||
assert "ENV USER=dev" in df
|
|
||||||
|
|
||||||
def test_no_home_env_without_user(self) -> None:
|
|
||||||
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
|
|
||||||
df = compile_dockerfile(manifest)
|
|
||||||
assert "ENV HOME=" not in df
|
|
||||||
assert "ENV USER=" not in df
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetManifestHomeDir:
|
|
||||||
"""Tests for get_manifest_home_dir."""
|
|
||||||
|
|
||||||
def test_with_user_name(self) -> None:
|
|
||||||
manifest = {"user": {"name": "dev", "uid": 1001, "gid": 1001}}
|
|
||||||
assert get_manifest_home_dir(manifest) == "/home/dev"
|
|
||||||
|
|
||||||
def test_without_user(self) -> None:
|
|
||||||
manifest = {"base_image": "ubuntu:24.04"}
|
|
||||||
assert get_manifest_home_dir(manifest) == "/root"
|
|
||||||
|
|
||||||
def test_with_empty_user_name(self) -> None:
|
|
||||||
manifest = {"user": {"name": "", "uid": 1001, "gid": 1001}}
|
|
||||||
assert get_manifest_home_dir(manifest) == "/root"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCompileEntrypoint:
|
|
||||||
"""Tests for compile_entrypoint."""
|
|
||||||
|
|
||||||
def test_includes_shebang_and_set_e(self) -> None:
|
|
||||||
manifest = {"scripts": {"startup": ["echo hello"]}}
|
|
||||||
ep = compile_entrypoint(manifest)
|
|
||||||
assert "#!/bin/bash" in ep
|
|
||||||
assert "set -e" in ep
|
|
||||||
|
|
||||||
def test_includes_startup_scripts(self) -> None:
|
|
||||||
manifest = {"scripts": {"startup": ["echo hello", "echo world"]}}
|
|
||||||
ep = compile_entrypoint(manifest)
|
|
||||||
assert "echo hello" in ep
|
|
||||||
assert "echo world" in ep
|
|
||||||
|
|
||||||
def test_ends_with_exec(self) -> None:
|
|
||||||
manifest: dict = {"scripts": {"startup": []}}
|
|
||||||
ep = compile_entrypoint(manifest)
|
|
||||||
assert 'exec "$@"' in ep
|
|
||||||
|
|
||||||
|
|
||||||
class TestCompileCompose:
|
|
||||||
"""Tests for compile_compose."""
|
|
||||||
|
|
||||||
def test_includes_image_and_container_name(self) -> None:
|
|
||||||
manifest = {"name": "test", "interface_type": "terminal"}
|
|
||||||
vars_dict = {"IMAGE_TAG": "test:v1", "INSTANCE_NAME": "test-1"}
|
|
||||||
compose = compile_compose(manifest, vars_dict)
|
|
||||||
assert "image: test:v1" in compose
|
|
||||||
assert "container_name: test-1" in compose
|
|
||||||
|
|
||||||
def test_terminal_fields(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"name": "test",
|
|
||||||
"interface_type": "terminal",
|
|
||||||
"runtime": {"stdin_open": True, "tty": True, "working_dir": "/workspace"},
|
|
||||||
}
|
|
||||||
compose = compile_compose(manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n"})
|
|
||||||
assert "stdin_open: true" in compose
|
|
||||||
assert "tty: true" in compose
|
|
||||||
assert "working_dir: /workspace" in compose
|
|
||||||
|
|
||||||
def test_web_ports(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"name": "test",
|
|
||||||
"interface_type": "web",
|
|
||||||
"default_port": 8080,
|
|
||||||
}
|
|
||||||
compose = compile_compose(
|
|
||||||
manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n", "TOOL_PORT": "3000"}
|
|
||||||
)
|
|
||||||
assert "3000:8080" in compose
|
|
||||||
|
|
||||||
def test_user_override(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"name": "test",
|
|
||||||
"interface_type": "terminal",
|
|
||||||
"user": {"uid": 1001, "gid": 1001},
|
|
||||||
}
|
|
||||||
compose = compile_compose(manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n"})
|
|
||||||
assert "user: 1001:1001" in compose
|
|
||||||
|
|
||||||
def test_mounts_resolved(self) -> None:
|
|
||||||
manifest = {
|
|
||||||
"name": "test",
|
|
||||||
"interface_type": "terminal",
|
|
||||||
"mounts": [
|
|
||||||
{"name": "ws", "target": "/workspace", "source_type": "repo"},
|
|
||||||
{
|
|
||||||
"name": "ssh",
|
|
||||||
"target": "/home/user/.ssh",
|
|
||||||
"source_type": "ssh_key",
|
|
||||||
"readonly": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
compose = compile_compose(
|
|
||||||
manifest,
|
|
||||||
{
|
|
||||||
"IMAGE_TAG": "t",
|
|
||||||
"INSTANCE_NAME": "n",
|
|
||||||
"REPO_PATH": "/repos/myrepo",
|
|
||||||
"SSH_PATH": "/keys/ssh",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert "/repos/myrepo:/workspace" in compose
|
|
||||||
assert "/keys/ssh:/home/user/.ssh:ro" in compose
|
|
||||||
|
|
||||||
def test_extra_volumes_appended(self) -> None:
|
|
||||||
manifest = {"name": "test", "interface_type": "terminal"}
|
|
||||||
compose = compile_compose(
|
|
||||||
manifest,
|
|
||||||
{
|
|
||||||
"IMAGE_TAG": "t",
|
|
||||||
"INSTANCE_NAME": "n",
|
|
||||||
"EXTRA_VOLUMES": [{"source": "/host/x", "target": "/container/x"}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert "/host/x:/container/x" in compose
|
|
||||||
|
|
||||||
|
|
||||||
class TestComputeImageTag:
|
|
||||||
"""Tests for compute_image_tag."""
|
|
||||||
|
|
||||||
def test_is_deterministic(self) -> None:
|
|
||||||
manifest = {"name": "test", "packages": {"apt": ["curl"]}}
|
|
||||||
tag1 = compute_image_tag("My Tool", manifest)
|
|
||||||
tag2 = compute_image_tag("My Tool", manifest)
|
|
||||||
assert tag1 == tag2
|
|
||||||
|
|
||||||
def test_changes_with_content(self) -> None:
|
|
||||||
manifest1 = {"name": "test", "packages": {"apt": ["curl"]}}
|
|
||||||
manifest2 = {"name": "test", "packages": {"apt": ["wget"]}}
|
|
||||||
tag1 = compute_image_tag("test", manifest1)
|
|
||||||
tag2 = compute_image_tag("test", manifest2)
|
|
||||||
assert tag1 != tag2
|
|
||||||
|
|
||||||
def test_lowercases_name(self) -> None:
|
|
||||||
manifest = {"name": "test"}
|
|
||||||
tag = compute_image_tag("My Tool", manifest)
|
|
||||||
assert "my-tool" in tag
|
|
||||||
|
|
||||||
def test_valid_docker_reference(self) -> None:
|
|
||||||
manifest = {"name": "test"}
|
|
||||||
tag = compute_image_tag("test", manifest)
|
|
||||||
assert tag.startswith("headquarter/test-")
|
|
||||||
assert tag.endswith(":latest")
|
|
||||||
|
|
||||||
|
|
||||||
class TestMergeWithConfig:
|
|
||||||
"""Tests for merge_with_config (ConfigProfile only)."""
|
|
||||||
|
|
||||||
def test_no_profile_returns_manifest_unchanged(self) -> None:
|
|
||||||
manifest = {"name": "test"}
|
|
||||||
result = merge_with_config(manifest)
|
|
||||||
assert result["name"] == "test"
|
|
||||||
assert result["_extra_env"] == {}
|
|
||||||
assert result["_extra_volumes"] == []
|
|
||||||
|
|
||||||
def test_profile_env_vars(self) -> None:
|
|
||||||
manifest = {"name": "test"}
|
|
||||||
profile = {"environment_variables": {"FOO": "bar"}}
|
|
||||||
result = merge_with_config(manifest, profile)
|
|
||||||
assert result["_extra_env"]["FOO"] == "bar"
|
|
||||||
|
|
||||||
def test_profile_mounts(self) -> None:
|
|
||||||
manifest = {"name": "test"}
|
|
||||||
profile = {"mounts": [{"source": "/host", "target": "/container"}]}
|
|
||||||
result = merge_with_config(manifest, profile)
|
|
||||||
assert len(result["_extra_volumes"]) == 1
|
|
||||||
|
|
||||||
def test_profile_port_override(self) -> None:
|
|
||||||
manifest = {"name": "test", "default_port": 8080}
|
|
||||||
profile = {"hints": {"port_override": 3000}}
|
|
||||||
result = merge_with_config(manifest, profile)
|
|
||||||
assert result["default_port"] == 3000
|
|
||||||
|
|
||||||
def test_profile_start_command(self) -> None:
|
|
||||||
manifest = {"name": "test", "runtime": {"command": ["/bin/bash"]}}
|
|
||||||
profile = {"hints": {"start_command": "/bin/sh"}}
|
|
||||||
result = merge_with_config(manifest, profile)
|
|
||||||
assert result["runtime"]["command"] == ["/bin/sh"]
|
|
||||||
|
|
||||||
def test_profile_working_directory(self) -> None:
|
|
||||||
manifest = {"name": "test"}
|
|
||||||
profile = {"hints": {"working_directory": "/workspace"}}
|
|
||||||
result = merge_with_config(manifest, profile)
|
|
||||||
assert result["runtime"]["working_dir"] == "/workspace"
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface Session {
|
|||||||
repository_id: string;
|
repository_id: string;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
|
workspace_name?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
container_status?: string;
|
container_status?: string;
|
||||||
@@ -160,6 +161,17 @@ export async function deleteInstance(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInstance(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string,
|
||||||
|
): Promise<ToolInstance> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserSessions(): Promise<Session[]> {
|
export async function getUserSessions(): Promise<Session[]> {
|
||||||
const response = await apiClient.get("/users/me/sessions");
|
const response = await apiClient.get("/users/me/sessions");
|
||||||
return response.data.sessions;
|
return response.data.sessions;
|
||||||
@@ -188,6 +200,19 @@ export async function checkInstanceHealth(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function renameInstance(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string,
|
||||||
|
displayName: string,
|
||||||
|
): Promise<{ id: string; name: string; display_name: string }> {
|
||||||
|
const response = await apiClient.patch(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||||
|
{ display_name: displayName },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function recreateInstanceTunnel(
|
export async function recreateInstanceTunnel(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
|
||||||
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserSessions } from "../api/sessions";
|
|
||||||
import type { Session } from "../api/sessions";
|
import type { Session } from "../api/sessions";
|
||||||
import { useTheme } from "../hooks/use-theme";
|
import { useTheme } from "../hooks/use-theme";
|
||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../state/auth";
|
||||||
@@ -10,8 +8,10 @@ import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|||||||
import { EventProvider } from "../state/events";
|
import { EventProvider } from "../state/events";
|
||||||
import { ToastProvider } from "../state/toast";
|
import { ToastProvider } from "../state/toast";
|
||||||
import { NotificationProvider } from "../state/notifications";
|
import { NotificationProvider } from "../state/notifications";
|
||||||
|
import { SessionOperationsProvider } from "../state/session-operations";
|
||||||
import { EventToastBridge } from "./features/notification/event-toast-bridge";
|
import { EventToastBridge } from "./features/notification/event-toast-bridge";
|
||||||
import { NotificationCenter } from "./features/notification/notification-center";
|
import { NotificationCenter } from "./features/notification/notification-center";
|
||||||
|
import { SessionProgressPanel } from "./features/session/session-progress-panel";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { MobileNav } from "./features/mobile/mobile-nav";
|
import { MobileNav } from "./features/mobile/mobile-nav";
|
||||||
import { StartToolFAB } from "./features/tool/start-tool-fab";
|
import { StartToolFAB } from "./features/tool/start-tool-fab";
|
||||||
@@ -41,23 +41,32 @@ const SessionItem = ({ session }: { session: Session }) => {
|
|||||||
// - Everything else falls back to the project page
|
// - Everything else falls back to the project page
|
||||||
const hasTerminal = session.tool_type_interfaces.includes("terminal");
|
const hasTerminal = session.tool_type_interfaces.includes("terminal");
|
||||||
const hasWeb = session.tool_type_interfaces.includes("web");
|
const hasWeb = session.tool_type_interfaces.includes("web");
|
||||||
const href = session.url && hasWeb
|
const href =
|
||||||
? session.url
|
session.url && hasWeb
|
||||||
: hasTerminal
|
? session.url
|
||||||
? `/instances/${session.id}/terminal`
|
: hasTerminal
|
||||||
: `/projects/${session.project_id}`;
|
? `/instances/${session.id}/terminal`
|
||||||
|
: `/projects/${session.project_id}`;
|
||||||
|
|
||||||
|
const tooltipParts = [session.display_name, session.project_name];
|
||||||
|
if (session.workspace_name) tooltipParts.push(session.workspace_name);
|
||||||
|
else if (session.repository_name) tooltipParts.push(session.repository_name);
|
||||||
|
tooltipParts.push(`(${session.status})`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target={`session-${session.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
className="nav-item session-item"
|
className="nav-item session-item"
|
||||||
title={`${session.display_name} (${session.status})`}
|
title={tooltipParts.join(" ")}
|
||||||
>
|
>
|
||||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
||||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||||
<span className="session-name">{session.display_name}</span>
|
<span className="session-name">
|
||||||
|
{session.display_name}
|
||||||
|
<span className="session-tool">{session.tool_type_name}</span>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -65,7 +74,7 @@ const SessionItem = ({ session }: { session: Session }) => {
|
|||||||
export const AppShell = () => {
|
export const AppShell = () => {
|
||||||
useTheme();
|
useTheme();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { sessions, setAllSessions } = useSessions();
|
const { sessions } = useSessions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isMobile = useMobileViewport();
|
const isMobile = useMobileViewport();
|
||||||
const isMobileTerminal =
|
const isMobileTerminal =
|
||||||
@@ -73,33 +82,17 @@ export const AppShell = () => {
|
|||||||
location.pathname.includes("/instances/") &&
|
location.pathname.includes("/instances/") &&
|
||||||
location.pathname.includes("/terminal");
|
location.pathname.includes("/terminal");
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await getUserSessions();
|
|
||||||
setAllSessions(data);
|
|
||||||
} catch {
|
|
||||||
// Silently fail - sessions are optional
|
|
||||||
}
|
|
||||||
}, [setAllSessions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadSessions();
|
|
||||||
// Poll every 30 seconds (reduced from 10s to avoid ERR_NETWORK_CHANGED from Docker network changes)
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
void loadSessions();
|
|
||||||
}, 30000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [loadSessions]);
|
|
||||||
|
|
||||||
if (isMobileTerminal) {
|
if (isMobileTerminal) {
|
||||||
return (
|
return (
|
||||||
<EventProvider>
|
<EventProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<EventToastBridge />
|
<SessionOperationsProvider>
|
||||||
<div className="shell mobile-terminal-shell">
|
<EventToastBridge />
|
||||||
<Outlet />
|
<div className="shell mobile-terminal-shell">
|
||||||
</div>
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</SessionOperationsProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</EventProvider>
|
</EventProvider>
|
||||||
@@ -110,81 +103,84 @@ export const AppShell = () => {
|
|||||||
<EventProvider>
|
<EventProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<EventToastBridge />
|
<SessionOperationsProvider>
|
||||||
<div className="shell">
|
<EventToastBridge />
|
||||||
<header className="shell-header">
|
<SessionProgressPanel />
|
||||||
<Link className="brand" to="/">
|
<div className="shell">
|
||||||
Headquarter
|
<header className="shell-header">
|
||||||
</Link>
|
<Link className="brand" to="/">
|
||||||
<div className="header-actions">
|
Headquarter
|
||||||
<NotificationCenter isMobileTerminal={isMobileTerminal} />
|
|
||||||
<Link className="user-chip" to="/profile">
|
|
||||||
{user?.name ?? "User"}
|
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<div className="header-actions">
|
||||||
className="ghost-button"
|
<NotificationCenter isMobileTerminal={isMobileTerminal} />
|
||||||
onClick={() => {
|
<Link className="user-chip" to="/profile">
|
||||||
void logout();
|
{user?.name ?? "User"}
|
||||||
}}
|
</Link>
|
||||||
type="button"
|
<button
|
||||||
>
|
className="ghost-button"
|
||||||
<Icon name="logout" size="sm" />
|
onClick={() => {
|
||||||
Logout
|
void logout();
|
||||||
</button>
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="logout" size="sm" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="shell-body">
|
||||||
|
{!isMobile && (
|
||||||
|
<aside className="shell-nav" aria-label="Primary navigation">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const activeCount = sessions.filter(
|
||||||
|
(s) => s.status === "running",
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive ? "nav-item nav-item-active" : "nav-item"
|
||||||
|
}
|
||||||
|
end={item.to === "/"}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} size="sm" />
|
||||||
|
{item.label}
|
||||||
|
{item.badge === "sessions" && activeCount > 0 && (
|
||||||
|
<span className="nav-badge">{activeCount}</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="nav-divider" />
|
||||||
|
<div className="nav-section-title">Live sessions</div>
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionItem key={session.id} session={session} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="shell-body">
|
{isMobile && (
|
||||||
{!isMobile && (
|
<MobileNav
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
sessionCount={
|
||||||
{NAV_ITEMS.map((item) => {
|
sessions.filter((s) => s.status === "running").length
|
||||||
const activeCount = sessions.filter(
|
}
|
||||||
(s) => s.status === "running",
|
/>
|
||||||
).length;
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
isActive ? "nav-item nav-item-active" : "nav-item"
|
|
||||||
}
|
|
||||||
end={item.to === "/"}
|
|
||||||
>
|
|
||||||
<Icon name={item.icon} size="sm" />
|
|
||||||
{item.label}
|
|
||||||
{item.badge === "sessions" && activeCount > 0 && (
|
|
||||||
<span className="nav-badge">{activeCount}</span>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{sessions.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="nav-divider" />
|
|
||||||
<div className="nav-section-title">Live sessions</div>
|
|
||||||
{sessions.map((session) => (
|
|
||||||
<SessionItem key={session.id} session={session} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
)}
|
)}
|
||||||
|
<StartToolFAB />
|
||||||
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
</SessionOperationsProvider>
|
||||||
{isMobile && (
|
|
||||||
<MobileNav
|
|
||||||
sessionCount={
|
|
||||||
sessions.filter((s) => s.status === "running").length
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<StartToolFAB />
|
|
||||||
</div>
|
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</EventProvider>
|
</EventProvider>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Icon } from "../../icon";
|
import { Icon } from "../../icon";
|
||||||
import { validateGitUrl } from "../../../api/config_profiles";
|
import { validateGitUrl } from "../../../api/config-profiles";
|
||||||
import type { GitMount, GitMountMapping } from "../../../api/config_profiles";
|
import type { GitMount, GitMountMapping } from "../../../api/config-profiles";
|
||||||
|
|
||||||
interface GitMountEditorProps {
|
interface GitMountEditorProps {
|
||||||
mounts: GitMount[];
|
mounts: GitMount[];
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, act } from "@testing-library/react";
|
import { render, act } from "@testing-library/react";
|
||||||
import { EventToastBridge } from "./event-toast-bridge";
|
import { EventToastBridge } from "./event-toast-bridge";
|
||||||
import { useEventContext } from "../state/events";
|
import { useEventContext } from "../../../state/events";
|
||||||
import { getUserConfig } from "../../../api/settings";
|
import { getUserConfig } from "../../../api/settings";
|
||||||
import { handleEventToast } from "./toast-rules";
|
import { handleEventToast } from "../../toast-rules";
|
||||||
import type { InstanceEventPayload } from "../../../types/events";
|
import type { InstanceEventPayload } from "../../../types/events";
|
||||||
|
|
||||||
vi.mock("../state/events", () => ({
|
vi.mock("../../../state/events", () => ({
|
||||||
useEventContext: vi.fn(),
|
useEventContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ vi.mock("../../../api/settings", () => ({
|
|||||||
getUserConfig: vi.fn(),
|
getUserConfig: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./toast-rules", async (importOriginal) => {
|
vi.mock("../../toast-rules", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("./toast-rules")>();
|
const actual = await importOriginal<typeof import("../../toast-rules")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
handleEventToast: vi.fn(),
|
handleEventToast: vi.fn(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
import { NotificationCenter } from "./notification-center";
|
import { NotificationCenter } from "./notification-center";
|
||||||
import { NotificationProvider } from "../state/notifications";
|
import { NotificationProvider } from "../../../state/notifications";
|
||||||
|
|
||||||
vi.mock("../../../api/notifications", () => ({
|
vi.mock("../../../api/notifications", () => ({
|
||||||
getNotifications: vi.fn(),
|
getNotifications: vi.fn(),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useNotifications } from "../../../hooks/use-notifications";
|
import { useNotifications } from "../../../hooks/use-notifications";
|
||||||
|
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
|
||||||
import { NotificationItem } from "./notification-item";
|
import { NotificationItem } from "./notification-item";
|
||||||
import { Icon } from "../../icon";
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
@@ -22,15 +24,39 @@ export function NotificationCenter({
|
|||||||
setIsDropdownOpen,
|
setIsDropdownOpen,
|
||||||
} = useNotifications();
|
} = useNotifications();
|
||||||
|
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const bellRef = useRef<HTMLButtonElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (!bellRef.current) return;
|
||||||
|
const rect = bellRef.current.getBoundingClientRect();
|
||||||
|
if (isMobile) {
|
||||||
|
setDropdownStyle({
|
||||||
|
top: rect.bottom + 6,
|
||||||
|
left: "1rem",
|
||||||
|
right: "1rem",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setDropdownStyle({
|
||||||
|
top: rect.bottom + 6,
|
||||||
|
right: window.innerWidth - rect.right,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDropdownOpen) return;
|
if (!isDropdownOpen) return;
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
if (
|
if (
|
||||||
dropdownRef.current &&
|
dropdownRef.current &&
|
||||||
!dropdownRef.current.contains(e.target as Node)
|
!dropdownRef.current.contains(target) &&
|
||||||
|
!bellRef.current?.contains(target)
|
||||||
) {
|
) {
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
}
|
}
|
||||||
@@ -42,14 +68,20 @@ export function NotificationCenter({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
updatePosition();
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
};
|
};
|
||||||
}, [isDropdownOpen, setIsDropdownOpen]);
|
}, [isDropdownOpen, setIsDropdownOpen, updatePosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDropdownOpen) {
|
if (isDropdownOpen) {
|
||||||
@@ -66,6 +98,7 @@ export function NotificationCenter({
|
|||||||
return (
|
return (
|
||||||
<div className="notification-center">
|
<div className="notification-center">
|
||||||
<button
|
<button
|
||||||
|
ref={bellRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="notification-bell"
|
className="notification-bell"
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
@@ -79,56 +112,68 @@ export function NotificationCenter({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isDropdownOpen && (
|
{isDropdownOpen &&
|
||||||
<div
|
createPortal(
|
||||||
ref={dropdownRef}
|
<>
|
||||||
role="dialog"
|
{isMobile && (
|
||||||
aria-label="Notifications"
|
<div
|
||||||
className="notification-dropdown"
|
className="notification-dropdown-backdrop"
|
||||||
>
|
onClick={() => setIsDropdownOpen(false)}
|
||||||
<div className="notification-dropdown-header">
|
aria-hidden="true"
|
||||||
<span>Notifications</span>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="notification-list">
|
|
||||||
{notifications.length === 0 ? (
|
|
||||||
<li className="notification-empty">No notifications</li>
|
|
||||||
) : (
|
|
||||||
notifications.map((n) => (
|
|
||||||
<NotificationItem
|
|
||||||
key={n.id}
|
|
||||||
notification={n}
|
|
||||||
onMarkRead={markRead}
|
|
||||||
onDismiss={dismiss}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</ul>
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Notifications"
|
||||||
|
className="notification-dropdown"
|
||||||
|
style={dropdownStyle}
|
||||||
|
>
|
||||||
|
<div className="notification-dropdown-header">
|
||||||
|
<span>Notifications</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{notifications.length > 0 && (
|
<ul className="notification-list">
|
||||||
<div className="notification-dropdown-footer">
|
{notifications.length === 0 ? (
|
||||||
<button
|
<li className="notification-empty">No notifications</li>
|
||||||
type="button"
|
) : (
|
||||||
className="notification-mark-all"
|
notifications.map((n) => (
|
||||||
onClick={() => {
|
<NotificationItem
|
||||||
void markAllRead();
|
key={n.id}
|
||||||
}}
|
notification={n}
|
||||||
>
|
onMarkRead={markRead}
|
||||||
Mark all as read
|
onDismiss={dismiss}
|
||||||
</button>
|
/>
|
||||||
<button
|
))
|
||||||
type="button"
|
)}
|
||||||
className="notification-clear-all"
|
</ul>
|
||||||
onClick={() => {
|
|
||||||
void clearAll();
|
{notifications.length > 0 && (
|
||||||
}}
|
<div className="notification-dropdown-footer">
|
||||||
>
|
<button
|
||||||
Clear all
|
type="button"
|
||||||
</button>
|
className="notification-mark-all"
|
||||||
|
onClick={() => {
|
||||||
|
void markAllRead();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="notification-clear-all"
|
||||||
|
onClick={() => {
|
||||||
|
void clearAll();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>,
|
||||||
</div>
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Icon } from "../../icon";
|
import { Icon } from "../../icon";
|
||||||
import { formatRelativeTime } from "../../../utils/time";
|
import { formatRelativeTime } from "../../../utils/time";
|
||||||
import type { NotificationItem as NotificationItemType } from "../../../api/notifications";
|
import type { NotificationItem as NotificationItemType } from "../../../api/notifications";
|
||||||
@@ -17,6 +18,18 @@ const severityIconMap: Record<string, IconName> = {
|
|||||||
success: "success",
|
success: "success",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatMetadataValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number") return String(value);
|
||||||
|
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function NotificationItem({
|
export function NotificationItem({
|
||||||
notification,
|
notification,
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
@@ -24,6 +37,11 @@ export function NotificationItem({
|
|||||||
}: NotificationItemProps) {
|
}: NotificationItemProps) {
|
||||||
const isUnread = notification.read_at === null;
|
const isUnread = notification.read_at === null;
|
||||||
const iconName = severityIconMap[notification.severity] ?? "info";
|
const iconName = severityIconMap[notification.severity] ?? "info";
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const hasDetails =
|
||||||
|
!!notification.message ||
|
||||||
|
Object.keys(notification.metadata || {}).length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
@@ -35,11 +53,36 @@ export function NotificationItem({
|
|||||||
</div>
|
</div>
|
||||||
<div className="notification-item-content">
|
<div className="notification-item-content">
|
||||||
<div className="notification-item-title">{notification.title}</div>
|
<div className="notification-item-title">{notification.title}</div>
|
||||||
|
{notification.message && (
|
||||||
|
<div className="notification-item-message">
|
||||||
|
{notification.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="notification-item-time">
|
<div className="notification-item-time">
|
||||||
{formatRelativeTime(notification.created_at)}
|
{formatRelativeTime(notification.created_at)}
|
||||||
</div>
|
</div>
|
||||||
|
{expanded && notification.metadata && (
|
||||||
|
<dl className="notification-item-metadata">
|
||||||
|
{Object.entries(notification.metadata).map(([key, value]) => (
|
||||||
|
<div key={key} className="notification-metadata-row">
|
||||||
|
<dt>{key.replace(/_/g, " ")}</dt>
|
||||||
|
<dd>{formatMetadataValue(value)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="notification-item-actions">
|
<div className="notification-item-actions">
|
||||||
|
{hasDetails && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="notification-item-action"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
aria-label={expanded ? "Hide details" : "Show details"}
|
||||||
|
>
|
||||||
|
{expanded ? "Less" : "Details"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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;
|
||||||
|
/** Mobile detail mode: shows back button instead of chevron toggle. */
|
||||||
|
showBackButton?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onConfirmDelete: () => void;
|
||||||
|
onCancelDelete: () => void;
|
||||||
|
onCreateWorkspace: (repoId: string) => void;
|
||||||
|
onWorkspaceAction: (
|
||||||
|
repoId: string,
|
||||||
|
workspace: WorkspaceSummary,
|
||||||
|
action: "sync" | "delete",
|
||||||
|
) => void;
|
||||||
|
onAddRepository?: () => void;
|
||||||
|
onCancelCreate: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectCard = ({
|
||||||
|
project,
|
||||||
|
expanded,
|
||||||
|
deleteConfirm,
|
||||||
|
workspaceLoading,
|
||||||
|
showCreateForm,
|
||||||
|
showBackButton = false,
|
||||||
|
onToggle,
|
||||||
|
onBack,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onConfirmDelete,
|
||||||
|
onCancelDelete,
|
||||||
|
onCreateWorkspace,
|
||||||
|
onWorkspaceAction,
|
||||||
|
onAddRepository,
|
||||||
|
onCancelCreate,
|
||||||
|
onCreated,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<article className="card project-card">
|
||||||
|
<div className="project-info-row">
|
||||||
|
{showBackButton ? (
|
||||||
|
<button
|
||||||
|
className="mobile-detail-back"
|
||||||
|
onClick={onBack}
|
||||||
|
type="button"
|
||||||
|
aria-label="Go back"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size="md" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="project-toggle"
|
||||||
|
onClick={onToggle}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={expanded ? "chevron-down" : "chevron-right"}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3>{project.name}</h3>
|
||||||
|
{project.description && (
|
||||||
|
<p className="muted project-description">{project.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!showBackButton && project.repositories?.length > 0 && (
|
||||||
|
<span className="repo-count">
|
||||||
|
{project.repositories.length} repo
|
||||||
|
{project.repositories.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{onAddRepository && (
|
||||||
|
<div className="project-add-repo">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
onClick={onAddRepository}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> Add Repository
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,114 +1,289 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
|
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
|
||||||
import * as gitRepositoriesApi from "../../../api/git-repositories";
|
import * as gitRepositoriesApi from "../../../api/git-repositories";
|
||||||
|
import * as sshKeysApi from "../../../api/ssh-keys";
|
||||||
|
|
||||||
const mockRepositories = [
|
const mockRepositories = [
|
||||||
{
|
{
|
||||||
id: "repo-1",
|
id: "repo-1",
|
||||||
name: "Main Repo",
|
name: "Main Repo",
|
||||||
path: "/repos/main",
|
path: "/repos/main",
|
||||||
project_id: "proj-1",
|
project_id: "proj-1",
|
||||||
owner_id: "user-1",
|
owner_id: "user-1",
|
||||||
is_mirror: false,
|
is_mirror: false,
|
||||||
remote_url: null,
|
remote_url: null,
|
||||||
last_push: null,
|
ssh_key_id: null,
|
||||||
created_at: null,
|
last_push: null,
|
||||||
},
|
created_at: null,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.mock("react-router-dom", async () => {
|
vi.mock("react-router-dom", async () => {
|
||||||
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
|
const actual =
|
||||||
return {
|
await vi.importActual<typeof import("react-router-dom")>(
|
||||||
...actual,
|
"react-router-dom",
|
||||||
useParams: () => ({ projectId: "proj-1" }),
|
);
|
||||||
};
|
return {
|
||||||
|
...actual,
|
||||||
|
useParams: () => ({ projectId: "proj-1" }),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("RepositoriesSettingsTab", () => {
|
describe("RepositoriesSettingsTab", () => {
|
||||||
it("opens create dialog and clones an existing repository", async () => {
|
it("opens create dialog and clones using full URL by default", async () => {
|
||||||
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
const listMock = vi
|
||||||
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
.spyOn(gitRepositoriesApi, "listRepositories")
|
||||||
|
.mockResolvedValue(mockRepositories);
|
||||||
|
const createMock = vi
|
||||||
|
.spyOn(gitRepositoriesApi, "createRepository")
|
||||||
|
.mockResolvedValue(mockRepositories[0]);
|
||||||
|
const parseMock = vi
|
||||||
|
.spyOn(gitRepositoriesApi, "parseGitUrl")
|
||||||
|
.mockResolvedValue({
|
||||||
|
original_url: "https://github.com/user/repo.git",
|
||||||
|
base_url: "https://github.com/user/repo.git",
|
||||||
|
is_valid_clone_url: true,
|
||||||
|
needs_parsing: false,
|
||||||
|
host: "github.com",
|
||||||
|
message: "Valid git repository URL",
|
||||||
|
error_code: null,
|
||||||
|
});
|
||||||
|
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([]);
|
||||||
|
|
||||||
render(<RepositoriesSettingsTab />);
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
target: { value: "New Repo" },
|
target: { value: "New Repo" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
fireEvent.change(
|
||||||
target: { value: "alice" },
|
screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i),
|
||||||
});
|
{
|
||||||
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
|
target: { value: "https://github.com/user/repo.git" },
|
||||||
target: { value: "demo" },
|
},
|
||||||
});
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
expect(parseMock).toHaveBeenCalledWith("https://github.com/user/repo.git");
|
||||||
name: "New Repo",
|
});
|
||||||
remote_url: "git@git.commumedia.org:alice/demo.git",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses advanced url fallback when requested", async () => {
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
|
||||||
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
|
||||||
|
|
||||||
render(<RepositoriesSettingsTab />);
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||||
|
name: "New Repo",
|
||||||
|
remote_url: "https://github.com/user/repo.git",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
it("switches to owner/repo mode and requires SSH key for generated SSH URL", async () => {
|
||||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
const listMock = vi
|
||||||
});
|
.spyOn(gitRepositoriesApi, "listRepositories")
|
||||||
|
.mockResolvedValue(mockRepositories);
|
||||||
|
const createMock = vi
|
||||||
|
.spyOn(gitRepositoriesApi, "createRepository")
|
||||||
|
.mockResolvedValue(mockRepositories[0]);
|
||||||
|
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "key-1",
|
||||||
|
name: "My Key",
|
||||||
|
public_key: "ssh-ed25519 AAA...",
|
||||||
|
created_at: "2024-01-01",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
render(<RepositoriesSettingsTab />);
|
||||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
|
||||||
target: { value: "New Repo" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /use full url instead/i }));
|
|
||||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), {
|
|
||||||
target: { value: "https://github.com/user/repo.git" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
name: "New Repo",
|
});
|
||||||
remote_url: "https://github.com/user/repo.git",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows validation when cloning without a remote url", async () => {
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
render(<RepositoriesSettingsTab />);
|
target: { value: "New Repo" },
|
||||||
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: /use owner\/repo instead/i }),
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||||
|
target: { value: "alice" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
|
||||||
|
target: { value: "demo" },
|
||||||
|
});
|
||||||
|
const sshSelect = screen.getByRole("combobox", { name: /ssh key/i });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(sshSelect.querySelector('option[value="key-1"]')).toBeTruthy(),
|
||||||
|
);
|
||||||
|
(sshSelect as HTMLSelectElement).value = "key-1";
|
||||||
|
fireEvent.change(sshSelect);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||||
});
|
name: "New Repo",
|
||||||
|
remote_url: "git@git.commumedia.org:alice/demo.git",
|
||||||
|
ssh_key_id: "key-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
it("accepts SSH URL in full URL mode when SSH key is selected", async () => {
|
||||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
const listMock = vi
|
||||||
target: { value: "New Repo" },
|
.spyOn(gitRepositoriesApi, "listRepositories")
|
||||||
});
|
.mockResolvedValue(mockRepositories);
|
||||||
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
const createMock = vi
|
||||||
target: { value: "" },
|
.spyOn(gitRepositoriesApi, "createRepository")
|
||||||
});
|
.mockResolvedValue(mockRepositories[0]);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
// SSH URLs are short-circuited client-side; parseGitUrl should NOT be called
|
||||||
|
const parseMock = vi
|
||||||
|
.spyOn(gitRepositoriesApi, "parseGitUrl")
|
||||||
|
.mockResolvedValue({
|
||||||
|
original_url: "",
|
||||||
|
base_url: null,
|
||||||
|
is_valid_clone_url: false,
|
||||||
|
needs_parsing: false,
|
||||||
|
host: null,
|
||||||
|
message: "",
|
||||||
|
error_code: null,
|
||||||
|
});
|
||||||
|
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "key-1",
|
||||||
|
name: "My Key",
|
||||||
|
public_key: "ssh-ed25519 AAA...",
|
||||||
|
created_at: "2024-01-01",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument();
|
render(<RepositoriesSettingsTab />);
|
||||||
});
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
|
target: { value: "New Repo" },
|
||||||
|
});
|
||||||
|
fireEvent.change(
|
||||||
|
screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i),
|
||||||
|
{
|
||||||
|
target: { value: "git@github.com:user/repo.git" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Client-side short-circuit shows valid without calling backend
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/valid git url/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(parseMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const sshSelect = screen.getByRole("combobox", { name: /ssh key/i });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(sshSelect.querySelector('option[value="key-1"]')).toBeTruthy(),
|
||||||
|
);
|
||||||
|
(sshSelect as HTMLSelectElement).value = "key-1";
|
||||||
|
fireEvent.change(sshSelect);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||||
|
name: "New Repo",
|
||||||
|
remote_url: "git@github.com:user/repo.git",
|
||||||
|
ssh_key_id: "key-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects SSH URL in full URL mode without an SSH key", async () => {
|
||||||
|
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(
|
||||||
|
mockRepositories,
|
||||||
|
);
|
||||||
|
const createMock = vi
|
||||||
|
.spyOn(gitRepositoriesApi, "createRepository")
|
||||||
|
.mockResolvedValue(mockRepositories[0]);
|
||||||
|
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "key-1",
|
||||||
|
name: "My Key",
|
||||||
|
public_key: "ssh-ed25519 AAA...",
|
||||||
|
created_at: "2024-01-01",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
|
target: { value: "New Repo" },
|
||||||
|
});
|
||||||
|
fireEvent.change(
|
||||||
|
screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i),
|
||||||
|
{
|
||||||
|
target: { value: "git@github.com:user/repo.git" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Do NOT select an SSH key
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/an ssh key is required for ssh urls/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(createMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows validation when cloning without a remote url", async () => {
|
||||||
|
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(
|
||||||
|
mockRepositories,
|
||||||
|
);
|
||||||
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
|
target: { value: "New Repo" },
|
||||||
|
});
|
||||||
|
fireEvent.change(
|
||||||
|
screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i),
|
||||||
|
{
|
||||||
|
target: { value: "" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/remote url is required for advanced cloning/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,343 +1,408 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../../../api/git-repositories";
|
import {
|
||||||
|
createRepository,
|
||||||
|
parseGitUrl,
|
||||||
|
type GitRepositoryCreate,
|
||||||
|
type URLParseResult,
|
||||||
|
} from "../../../api/git-repositories";
|
||||||
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
||||||
import { Icon } from "../../icon";
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
type CreateMode = "clone" | "blank";
|
type CreateMode = "clone" | "blank";
|
||||||
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
|
type UrlValidationStatus =
|
||||||
|
| "idle"
|
||||||
|
| "validating"
|
||||||
|
| "valid"
|
||||||
|
| "needs-parsing"
|
||||||
|
| "invalid";
|
||||||
|
|
||||||
interface RepositoryCreateDialogProps {
|
interface RepositoryCreateDialogProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated: () => Promise<void> | void;
|
onCreated: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
|
export const RepositoryCreateDialog = ({
|
||||||
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
projectId,
|
||||||
const [formName, setFormName] = useState("");
|
open,
|
||||||
const [owner, setOwner] = useState("");
|
title,
|
||||||
const [repoName, setRepoName] = useState("");
|
onClose,
|
||||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
onCreated,
|
||||||
const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
|
}: RepositoryCreateDialogProps) => {
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||||
const [urlValidation, setUrlValidation] = useState<{
|
const [formName, setFormName] = useState("");
|
||||||
status: UrlValidationStatus;
|
const [owner, setOwner] = useState("");
|
||||||
result: URLParseResult | null;
|
const [repoName, setRepoName] = useState("");
|
||||||
}>({ status: "idle", result: null });
|
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
|
||||||
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const [urlValidation, setUrlValidation] = useState<{
|
||||||
|
status: UrlValidationStatus;
|
||||||
|
result: URLParseResult | null;
|
||||||
|
}>({ status: "idle", result: null });
|
||||||
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const isSshUrl = (url: string): boolean => {
|
||||||
if (!open && debounceTimer.current) {
|
const u = url.trim().toLowerCase();
|
||||||
clearTimeout(debounceTimer.current);
|
return u.startsWith("git@") || u.startsWith("ssh://");
|
||||||
debounceTimer.current = null;
|
};
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open && debounceTimer.current) {
|
||||||
const loadKeys = async () => {
|
clearTimeout(debounceTimer.current);
|
||||||
try {
|
debounceTimer.current = null;
|
||||||
const data = await listSSHKeys();
|
}
|
||||||
setSshKeys(data);
|
}, [open]);
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void loadKeys();
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (!useAdvancedUrl) {
|
const loadKeys = async () => {
|
||||||
setUrlValidation({ status: "idle", result: null });
|
try {
|
||||||
return;
|
const data = await listSSHKeys();
|
||||||
}
|
setSshKeys(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadKeys();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
if (debounceTimer.current) {
|
useEffect(() => {
|
||||||
clearTimeout(debounceTimer.current);
|
if (!open) return;
|
||||||
}
|
if (!useAdvancedUrl) {
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!advancedUrl.trim()) {
|
if (debounceTimer.current) {
|
||||||
setUrlValidation({ status: "idle", result: null });
|
clearTimeout(debounceTimer.current);
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setUrlValidation({ status: "validating", result: null });
|
if (!advancedUrl.trim()) {
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
debounceTimer.current = setTimeout(async () => {
|
setUrlValidation({ status: "validating", result: null });
|
||||||
try {
|
|
||||||
const result = await parseGitUrl(advancedUrl.trim());
|
|
||||||
if (result.is_valid_clone_url) {
|
|
||||||
setUrlValidation({ status: "valid", result });
|
|
||||||
} else if (result.needs_parsing) {
|
|
||||||
setUrlValidation({ status: "needs-parsing", result });
|
|
||||||
} else {
|
|
||||||
setUrlValidation({ status: "invalid", result });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setUrlValidation({ status: "invalid", result: null });
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
debounceTimer.current = setTimeout(async () => {
|
||||||
if (debounceTimer.current) {
|
const trimmed = advancedUrl.trim();
|
||||||
clearTimeout(debounceTimer.current);
|
// Short-circuit SSH URLs — the backend parseGitUrl may not always
|
||||||
}
|
// recognise them, but they are valid clone URLs by definition.
|
||||||
};
|
if (isSshUrl(trimmed)) {
|
||||||
}, [advancedUrl, open, useAdvancedUrl]);
|
setUrlValidation({
|
||||||
|
status: "valid",
|
||||||
|
result: {
|
||||||
|
original_url: trimmed,
|
||||||
|
base_url: trimmed,
|
||||||
|
is_valid_clone_url: true,
|
||||||
|
needs_parsing: false,
|
||||||
|
host: null,
|
||||||
|
message: "Valid SSH git URL",
|
||||||
|
error_code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await parseGitUrl(trimmed);
|
||||||
|
if (result.is_valid_clone_url) {
|
||||||
|
setUrlValidation({ status: "valid", result });
|
||||||
|
} else if (result.needs_parsing) {
|
||||||
|
setUrlValidation({ status: "needs-parsing", result });
|
||||||
|
} else {
|
||||||
|
setUrlValidation({ status: "invalid", result });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUrlValidation({ status: "invalid", result: null });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
const resetForm = () => {
|
return () => {
|
||||||
setCreateMode("clone");
|
if (debounceTimer.current) {
|
||||||
setFormName("");
|
clearTimeout(debounceTimer.current);
|
||||||
setOwner("");
|
}
|
||||||
setRepoName("");
|
};
|
||||||
setAdvancedUrl("");
|
}, [advancedUrl, open, useAdvancedUrl]);
|
||||||
setUseAdvancedUrl(true);
|
|
||||||
setFormError(null);
|
|
||||||
setUrlValidation({ status: "idle", result: null });
|
|
||||||
setSelectedSshKey("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const resetForm = () => {
|
||||||
resetForm();
|
setCreateMode("clone");
|
||||||
onClose();
|
setFormName("");
|
||||||
};
|
setOwner("");
|
||||||
|
setRepoName("");
|
||||||
|
setAdvancedUrl("");
|
||||||
|
setUseAdvancedUrl(true);
|
||||||
|
setFormError(null);
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
setSelectedSshKey("");
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleClose = () => {
|
||||||
event.preventDefault();
|
resetForm();
|
||||||
setFormError(null);
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
if (!formName.trim()) {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
setFormError("Repository name is required");
|
event.preventDefault();
|
||||||
return;
|
setFormError(null);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (!formName.trim()) {
|
||||||
const input: GitRepositoryCreate = {
|
setFormError("Repository name is required");
|
||||||
name: formName.trim(),
|
return;
|
||||||
remote_url: undefined,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (createMode === "clone") {
|
try {
|
||||||
if (useAdvancedUrl) {
|
const input: GitRepositoryCreate = {
|
||||||
if (!advancedUrl.trim()) {
|
name: formName.trim(),
|
||||||
setFormError("Remote URL is required for advanced cloning");
|
remote_url: undefined,
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
input.remote_url = advancedUrl.trim();
|
|
||||||
} else {
|
|
||||||
if (!owner.trim() || !repoName.trim()) {
|
|
||||||
setFormError("Owner and repository name are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
|
||||||
}
|
|
||||||
if (selectedSshKey) {
|
|
||||||
input.ssh_key_id = selectedSshKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await createRepository(projectId, input);
|
if (createMode === "clone") {
|
||||||
handleClose();
|
let remoteUrl: string;
|
||||||
await onCreated();
|
if (useAdvancedUrl) {
|
||||||
} catch (error: unknown) {
|
if (!advancedUrl.trim()) {
|
||||||
const response = error as { response?: { data?: { detail?: string } } };
|
setFormError("Remote URL is required for advanced cloning");
|
||||||
const detail = response.response?.data?.detail;
|
return;
|
||||||
setFormError(typeof detail === "string" ? detail : "Failed to create repository");
|
}
|
||||||
}
|
remoteUrl = advancedUrl.trim();
|
||||||
};
|
} else {
|
||||||
|
if (!owner.trim() || !repoName.trim()) {
|
||||||
|
setFormError("Owner and repository name are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remoteUrl = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||||
|
}
|
||||||
|
|
||||||
const handleUseSuggestedUrl = () => {
|
if (isSshUrl(remoteUrl) && !selectedSshKey) {
|
||||||
if (urlValidation.result?.base_url) {
|
setFormError("An SSH key is required for SSH URLs");
|
||||||
setAdvancedUrl(urlValidation.result.base_url);
|
return;
|
||||||
setUrlValidation({ status: "idle", result: null });
|
}
|
||||||
setFormError(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUrlInputClass = () => {
|
input.remote_url = remoteUrl;
|
||||||
switch (urlValidation.status) {
|
if (selectedSshKey) {
|
||||||
case "valid":
|
input.ssh_key_id = selectedSshKey;
|
||||||
return "valid-url";
|
}
|
||||||
case "needs-parsing":
|
}
|
||||||
return "needs-parsing-url";
|
|
||||||
case "invalid":
|
|
||||||
return "invalid-url";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!open) return null;
|
await createRepository(projectId, input);
|
||||||
|
handleClose();
|
||||||
|
await onCreated();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const response = error as { response?: { data?: { detail?: string } } };
|
||||||
|
const detail = response.response?.data?.detail;
|
||||||
|
setFormError(
|
||||||
|
typeof detail === "string" ? detail : "Failed to create repository",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const handleUseSuggestedUrl = () => {
|
||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
if (urlValidation.result?.base_url) {
|
||||||
<div className="dialog">
|
setAdvancedUrl(urlValidation.result.base_url);
|
||||||
<h3>{title}</h3>
|
setUrlValidation({ status: "idle", result: null });
|
||||||
<p className="muted">
|
setFormError(null);
|
||||||
Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
|
}
|
||||||
</p>
|
};
|
||||||
<form onSubmit={handleSubmit} className="stack">
|
|
||||||
<div className="form-field">
|
const getUrlInputClass = () => {
|
||||||
<label>
|
switch (urlValidation.status) {
|
||||||
<input
|
case "valid":
|
||||||
type="radio"
|
return "valid-url";
|
||||||
name="repository-mode"
|
case "needs-parsing":
|
||||||
checked={createMode === "clone"}
|
return "needs-parsing-url";
|
||||||
onChange={() => setCreateMode("clone")}
|
case "invalid":
|
||||||
/>
|
return "invalid-url";
|
||||||
Clone existing repository
|
default:
|
||||||
</label>
|
return "";
|
||||||
<label>
|
}
|
||||||
<input
|
};
|
||||||
type="radio"
|
|
||||||
name="repository-mode"
|
if (!open) return null;
|
||||||
checked={createMode === "blank"}
|
|
||||||
onChange={() => setCreateMode("blank")}
|
return (
|
||||||
/>
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
Create blank repository
|
<div className="dialog">
|
||||||
</label>
|
<h3>{title}</h3>
|
||||||
</div>
|
<p className="muted">
|
||||||
<label className="form-field">
|
Clone an existing repository from git.commumedia.org, or create a
|
||||||
Repository name
|
blank bare repo here.
|
||||||
<input
|
</p>
|
||||||
type="text"
|
<form onSubmit={handleSubmit} className="stack">
|
||||||
value={formName}
|
<div className="form-field repo-mode-radios">
|
||||||
onChange={(event) => setFormName(event.target.value)}
|
<label className="repo-mode-label">
|
||||||
placeholder="repository-name"
|
<input
|
||||||
/>
|
type="radio"
|
||||||
</label>
|
name="repository-mode"
|
||||||
{createMode === "clone" && !useAdvancedUrl && (
|
checked={createMode === "clone"}
|
||||||
<>
|
onChange={() => setCreateMode("clone")}
|
||||||
<label className="form-field">
|
/>
|
||||||
Owner
|
<span>Clone existing</span>
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<label className="repo-mode-label">
|
||||||
value={owner}
|
<input
|
||||||
onChange={(event) => setOwner(event.target.value)}
|
type="radio"
|
||||||
placeholder="owner"
|
name="repository-mode"
|
||||||
/>
|
checked={createMode === "blank"}
|
||||||
</label>
|
onChange={() => setCreateMode("blank")}
|
||||||
<label className="form-field">
|
/>
|
||||||
Repository
|
<span>Create blank</span>
|
||||||
<input
|
</label>
|
||||||
type="text"
|
</div>
|
||||||
value={repoName}
|
<label className="form-field">
|
||||||
onChange={(event) => setRepoName(event.target.value)}
|
Repository name
|
||||||
placeholder="repo-name"
|
<input
|
||||||
/>
|
type="text"
|
||||||
</label>
|
value={formName}
|
||||||
<label className="form-field">
|
onChange={(event) => setFormName(event.target.value)}
|
||||||
SSH Key
|
placeholder="repository-name"
|
||||||
<select
|
/>
|
||||||
value={selectedSshKey}
|
</label>
|
||||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
{createMode === "clone" && !useAdvancedUrl && (
|
||||||
>
|
<>
|
||||||
<option value="">Select SSH key (optional)...</option>
|
<label className="form-field">
|
||||||
{sshKeys.map((k) => (
|
Owner
|
||||||
<option key={k.id} value={k.id}>
|
<input
|
||||||
{k.name}
|
type="text"
|
||||||
</option>
|
value={owner}
|
||||||
))}
|
onChange={(event) => setOwner(event.target.value)}
|
||||||
</select>
|
placeholder="owner"
|
||||||
</label>
|
/>
|
||||||
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
</label>
|
||||||
<button
|
<label className="form-field">
|
||||||
type="button"
|
Repository
|
||||||
className="secondary-button small"
|
<input
|
||||||
onClick={() => setUseAdvancedUrl(true)}
|
type="text"
|
||||||
>
|
value={repoName}
|
||||||
Use full URL instead
|
onChange={(event) => setRepoName(event.target.value)}
|
||||||
</button>
|
placeholder="repo-name"
|
||||||
</>
|
/>
|
||||||
)}
|
</label>
|
||||||
{createMode === "clone" && useAdvancedUrl && (
|
<label className="form-field">
|
||||||
<>
|
SSH Key
|
||||||
<label className="form-field">
|
<select
|
||||||
Remote URL
|
value={selectedSshKey}
|
||||||
<input
|
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||||
type="text"
|
>
|
||||||
value={advancedUrl}
|
<option value="">Select SSH key (optional)...</option>
|
||||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
{sshKeys.map((k) => (
|
||||||
placeholder="https://github.com/user/repo.git"
|
<option key={k.id} value={k.id}>
|
||||||
className={getUrlInputClass()}
|
{k.name}
|
||||||
/>
|
</option>
|
||||||
{urlValidation.status === "validating" && (
|
))}
|
||||||
<span className="validation-status validating">Validating...</span>
|
</select>
|
||||||
)}
|
</label>
|
||||||
{urlValidation.status === "valid" && (
|
<p className="muted">
|
||||||
<span className="validation-status valid">
|
SSH target: git@git.commumedia.org:{owner || "owner"}/
|
||||||
<Icon name="success" size="sm" /> Valid git URL
|
{repoName || "repo"}.git
|
||||||
</span>
|
</p>
|
||||||
)}
|
<button
|
||||||
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
type="button"
|
||||||
<div className="url-suggestion">
|
className="secondary-button small"
|
||||||
<span className="validation-status warning">
|
onClick={() => setUseAdvancedUrl(true)}
|
||||||
<Icon name="warning" size="sm" /> This looks like a browser URL
|
>
|
||||||
</span>
|
Use full URL instead
|
||||||
<div className="suggestion-actions">
|
</button>
|
||||||
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
</>
|
||||||
<button
|
)}
|
||||||
type="button"
|
{createMode === "clone" && useAdvancedUrl && (
|
||||||
className="secondary-button small"
|
<>
|
||||||
onClick={handleUseSuggestedUrl}
|
<label className="form-field">
|
||||||
>
|
Remote URL
|
||||||
Use Suggested
|
<input
|
||||||
</button>
|
type="text"
|
||||||
</div>
|
value={advancedUrl}
|
||||||
</div>
|
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||||
)}
|
placeholder="https://github.com/user/repo.git"
|
||||||
{urlValidation.status === "invalid" && (
|
className={getUrlInputClass()}
|
||||||
<span className="validation-status invalid">
|
/>
|
||||||
<Icon name="error" size="sm" /> Invalid URL
|
{urlValidation.status === "validating" && (
|
||||||
</span>
|
<span className="validation-status validating">
|
||||||
)}
|
Validating...
|
||||||
</label>
|
</span>
|
||||||
<label className="form-field">
|
)}
|
||||||
SSH Key
|
{urlValidation.status === "valid" && (
|
||||||
<select
|
<span className="validation-status valid">
|
||||||
value={selectedSshKey}
|
<Icon name="success" size="sm" /> Valid git URL
|
||||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
</span>
|
||||||
>
|
)}
|
||||||
<option value="">Select SSH key (optional)...</option>
|
{urlValidation.status === "needs-parsing" &&
|
||||||
{sshKeys.map((k) => (
|
urlValidation.result && (
|
||||||
<option key={k.id} value={k.id}>
|
<div className="url-suggestion">
|
||||||
{k.name}
|
<span className="validation-status warning">
|
||||||
</option>
|
<Icon name="warning" size="sm" /> This looks like a
|
||||||
))}
|
browser URL
|
||||||
</select>
|
</span>
|
||||||
</label>
|
<div className="suggestion-actions">
|
||||||
<button
|
<span className="suggested-url">
|
||||||
type="button"
|
Suggested: {urlValidation.result.base_url}
|
||||||
className="secondary-button small"
|
</span>
|
||||||
onClick={() => setUseAdvancedUrl(false)}
|
<button
|
||||||
>
|
type="button"
|
||||||
Use owner/repo instead
|
className="secondary-button small"
|
||||||
</button>
|
onClick={handleUseSuggestedUrl}
|
||||||
</>
|
>
|
||||||
)}
|
Use Suggested
|
||||||
{formError && (
|
</button>
|
||||||
<div className="error-message">
|
</div>
|
||||||
<p className="error-text">{formError}</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{urlValidation.status === "invalid" && (
|
||||||
<div className="dialog-actions">
|
<span className="validation-status invalid">
|
||||||
<button className="secondary-button" onClick={handleClose} type="button">
|
<Icon name="error" size="sm" /> Invalid URL
|
||||||
<Icon name="cancel" size="sm" />
|
</span>
|
||||||
Cancel
|
)}
|
||||||
</button>
|
</label>
|
||||||
<button className="primary-button" type="submit">
|
<label className="form-field">
|
||||||
<Icon name="add" size="sm" />
|
SSH Key
|
||||||
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
|
<select
|
||||||
</button>
|
value={selectedSshKey}
|
||||||
</div>
|
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||||
</form>
|
>
|
||||||
</div>
|
<option value="">Select SSH key (optional)...</option>
|
||||||
</div>
|
{sshKeys.map((k) => (
|
||||||
);
|
<option key={k.id} value={k.id}>
|
||||||
|
{k.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => setUseAdvancedUrl(false)}
|
||||||
|
>
|
||||||
|
Use owner/repo instead
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{formError && (
|
||||||
|
<div className="error-message">
|
||||||
|
<p className="error-text">{formError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={handleClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="cancel" size="sm" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
{createMode === "clone"
|
||||||
|
? "Clone Repository"
|
||||||
|
: "Create Blank Repository"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import type { Session } from "../../../api/sessions";
|
import type { Session } from "../../../api/sessions";
|
||||||
import { Icon } from "../../icon";
|
import { Icon } from "../../icon";
|
||||||
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
|
||||||
@@ -12,6 +12,7 @@ export interface SessionCardProps {
|
|||||||
onStop?: (session: Session) => void;
|
onStop?: (session: Session) => void;
|
||||||
onDelete?: (session: Session) => void;
|
onDelete?: (session: Session) => void;
|
||||||
onRecreateTunnel?: (session: Session) => void;
|
onRecreateTunnel?: (session: Session) => void;
|
||||||
|
onRename?: (session: Session, newName: string) => void;
|
||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
tunnelHealth?: {
|
tunnelHealth?: {
|
||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
@@ -43,12 +44,15 @@ export function SessionCard({
|
|||||||
onStop,
|
onStop,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRecreateTunnel,
|
onRecreateTunnel,
|
||||||
|
onRename,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
tunnelHealth = null,
|
tunnelHealth = null,
|
||||||
}: SessionCardProps) {
|
}: SessionCardProps) {
|
||||||
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
||||||
const [showActionSheet, setShowActionSheet] = useState(false);
|
const [showActionSheet, setShowActionSheet] = useState(false);
|
||||||
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
|
const [editName, setEditName] = useState(session.display_name);
|
||||||
|
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||||
|
const optionsRef = useRef<HTMLDivElement>(null);
|
||||||
const isMobile = useMobileViewport();
|
const isMobile = useMobileViewport();
|
||||||
|
|
||||||
const status = statusConfig[session.status] || {
|
const status = statusConfig[session.status] || {
|
||||||
@@ -58,37 +62,11 @@ export function SessionCard({
|
|||||||
const isTerminalOnly =
|
const isTerminalOnly =
|
||||||
session.tool_type_interfaces?.includes("terminal") &&
|
session.tool_type_interfaces?.includes("terminal") &&
|
||||||
!session.tool_type_interfaces?.includes("web");
|
!session.tool_type_interfaces?.includes("web");
|
||||||
const openHref = session.url
|
|
||||||
? session.url
|
|
||||||
: isTerminalOnly
|
|
||||||
? `/instances/${session.id}/terminal`
|
|
||||||
: undefined;
|
|
||||||
const hasTunnelError =
|
const hasTunnelError =
|
||||||
!isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
|
!isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
|
||||||
const hasAppError =
|
const hasAppError =
|
||||||
!isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
|
!isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
|
||||||
|
|
||||||
const handleStop = () => {
|
|
||||||
if (showStopConfirm) {
|
|
||||||
setShowStopConfirm(false);
|
|
||||||
onStop?.(session);
|
|
||||||
} else {
|
|
||||||
setShowStopConfirm(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (showDeleteConfirm) {
|
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
onDelete?.(session);
|
|
||||||
} else {
|
|
||||||
setShowDeleteConfirm(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelStop = () => setShowStopConfirm(false);
|
|
||||||
const handleCancelDelete = () => setShowDeleteConfirm(false);
|
|
||||||
|
|
||||||
const isActive = [
|
const isActive = [
|
||||||
"running",
|
"running",
|
||||||
"building",
|
"building",
|
||||||
@@ -98,17 +76,76 @@ export function SessionCard({
|
|||||||
"unhealthy",
|
"unhealthy",
|
||||||
].includes(session.status);
|
].includes(session.status);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!optionsOpen) return;
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
optionsRef.current &&
|
||||||
|
!optionsRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setOptionsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOptionsOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClick);
|
||||||
|
document.removeEventListener("keydown", handleKey);
|
||||||
|
};
|
||||||
|
}, [optionsOpen]);
|
||||||
|
|
||||||
|
const handleDeleteClick = () => {
|
||||||
|
setOptionsOpen(false);
|
||||||
|
if (window.confirm("Are you sure you want to delete this session?")) {
|
||||||
|
onDelete?.(session);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
|
<article className="card session-card">
|
||||||
{isBusy && (
|
|
||||||
<div className="session-busy-overlay">
|
|
||||||
<Icon name="loading" size="md" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="session-card-content">
|
<div className="session-card-content">
|
||||||
<div className="session-card-header">
|
<div className="session-card-header">
|
||||||
<div className="session-card-title">
|
<div className="session-card-title">
|
||||||
<h4>{session.display_name}</h4>
|
{isEditingName ? (
|
||||||
|
<div className="session-card-rename">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
onRename?.(session, editName);
|
||||||
|
setIsEditingName(false);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setEditName(session.display_name);
|
||||||
|
setIsEditingName(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (editName.trim() && editName !== session.display_name) {
|
||||||
|
onRename?.(session, editName);
|
||||||
|
}
|
||||||
|
setIsEditingName(false);
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h4
|
||||||
|
onClick={() => {
|
||||||
|
setEditName(session.display_name);
|
||||||
|
setIsEditingName(true);
|
||||||
|
}}
|
||||||
|
title="Click to rename"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{session.display_name}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
<div className="session-card-status-badges">
|
<div className="session-card-status-badges">
|
||||||
<span className={`status-badge ${status.color}`}>
|
<span className={`status-badge ${status.color}`}>
|
||||||
{status.label}
|
{status.label}
|
||||||
@@ -123,10 +160,21 @@ export function SessionCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted session-card-meta">
|
<p className="muted session-card-meta session-card-context">
|
||||||
{session.tool_type_name}
|
<strong>{session.project_name}</strong>
|
||||||
{session.project_name && ` · ${session.project_name}`}
|
{session.workspace_name && (
|
||||||
{session.repository_name && ` · ${session.repository_name}`}
|
<>
|
||||||
|
{" "}
|
||||||
|
/ <span>{session.workspace_name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!session.workspace_name && session.repository_name && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
/ <span>{session.repository_name}</span>
|
||||||
|
</>
|
||||||
|
)}{" "}
|
||||||
|
· {session.tool_type_name}
|
||||||
</p>
|
</p>
|
||||||
{session.clone_mode && (
|
{session.clone_mode && (
|
||||||
<p className="muted session-card-meta">
|
<p className="muted session-card-meta">
|
||||||
@@ -138,9 +186,13 @@ export function SessionCard({
|
|||||||
)}
|
)}
|
||||||
{session.url && (
|
{session.url && (
|
||||||
<p className="session-card-url">
|
<p className="session-card-url">
|
||||||
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-button"
|
||||||
|
onClick={() => onOpen?.(session)}
|
||||||
|
>
|
||||||
{session.url}
|
{session.url}
|
||||||
</a>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{session.created_at && (
|
{session.created_at && (
|
||||||
@@ -155,27 +207,15 @@ export function SessionCard({
|
|||||||
<div className="session-card-actions mobile">
|
<div className="session-card-actions mobile">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<>
|
<>
|
||||||
{openHref ? (
|
<button
|
||||||
<a
|
className="secondary-button mobile-primary"
|
||||||
href={openHref}
|
onClick={() => onOpen?.(session)}
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
disabled={isBusy}
|
||||||
className="secondary-button mobile-primary"
|
>
|
||||||
>
|
<Icon name="external" size="sm" />
|
||||||
<Icon name="external" size="sm" />
|
Open
|
||||||
Open
|
</button>
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="secondary-button mobile-primary"
|
|
||||||
onClick={() => onOpen?.(session)}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="ghost-button mobile-more"
|
className="ghost-button mobile-more"
|
||||||
onClick={() => setShowActionSheet(true)}
|
onClick={() => setShowActionSheet(true)}
|
||||||
@@ -187,99 +227,40 @@ export function SessionCard({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isActive && onStart && (
|
{!isActive && onStart && (
|
||||||
<button
|
<>
|
||||||
className="secondary-button mobile-primary"
|
<button
|
||||||
onClick={() => onStart(session)}
|
className="secondary-button mobile-primary"
|
||||||
type="button"
|
onClick={() => onStart(session)}
|
||||||
disabled={isBusy}
|
type="button"
|
||||||
>
|
disabled={isBusy}
|
||||||
<Icon name="play" size="sm" />
|
>
|
||||||
Start
|
<Icon name="play" size="sm" />
|
||||||
</button>
|
Start
|
||||||
)}
|
</button>
|
||||||
{!isActive && (
|
<button
|
||||||
<button
|
className="ghost-button mobile-more"
|
||||||
className="ghost-button mobile-more"
|
onClick={() => setShowActionSheet(true)}
|
||||||
onClick={() => setShowActionSheet(true)}
|
type="button"
|
||||||
type="button"
|
disabled={isBusy}
|
||||||
disabled={isBusy}
|
>
|
||||||
>
|
<Icon name="menu" size="sm" />
|
||||||
<Icon name="menu" size="sm" />
|
</button>
|
||||||
</button>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="session-card-actions">
|
<div className="session-card-actions">
|
||||||
{isActive && (
|
{isActive && onOpen && (
|
||||||
<>
|
<button
|
||||||
{openHref ? (
|
className="secondary-button small"
|
||||||
<a
|
onClick={() => onOpen(session)}
|
||||||
href={openHref}
|
type="button"
|
||||||
target="_blank"
|
disabled={isBusy}
|
||||||
rel="noopener noreferrer"
|
>
|
||||||
className="secondary-button small"
|
<Icon name="external" size="sm" />
|
||||||
>
|
<span className="action-label">Open</span>
|
||||||
<Icon name="external" size="sm" />
|
</button>
|
||||||
<span className="action-label">Open</span>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => onOpen?.(session)}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
<span className="action-label">Open</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isTerminalOnly && onRecreateTunnel && (
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => onRecreateTunnel(session)}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
title="Recreate Cloudflare tunnel"
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
<span className="action-label">Tunnel</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showStopConfirm ? (
|
|
||||||
<div className="confirm-inline">
|
|
||||||
<span className="confirm-text">Stop?</span>
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={handleStop}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={handleCancelStop}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={handleStop}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<Icon name="stop" size="sm" />
|
|
||||||
<span className="action-label">Stop</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isActive && onStart && (
|
{!isActive && onStart && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
@@ -292,35 +273,77 @@ export function SessionCard({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showDeleteConfirm ? (
|
<div className="session-options" ref={optionsRef}>
|
||||||
<div className="confirm-inline">
|
|
||||||
<span className="confirm-text">Delete?</span>
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={handleDelete}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={handleCancelDelete}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
className="ghost-button small danger-text"
|
className="ghost-button small"
|
||||||
onClick={handleDelete}
|
onClick={() => setOptionsOpen((prev) => !prev)}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={optionsOpen}
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="sm" />
|
<Icon name="more" size="sm" />
|
||||||
|
<span className="action-label">Options</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
{optionsOpen && (
|
||||||
|
<div className="session-options-dropdown" role="menu">
|
||||||
|
{isActive && onStop && (
|
||||||
|
<button
|
||||||
|
className="session-option-item"
|
||||||
|
onClick={() => {
|
||||||
|
setOptionsOpen(false);
|
||||||
|
onStop(session);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isActive && onStart && (
|
||||||
|
<button
|
||||||
|
className="session-option-item"
|
||||||
|
onClick={() => {
|
||||||
|
setOptionsOpen(false);
|
||||||
|
onStart(session);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isActive && !isTerminalOnly && onRecreateTunnel && (
|
||||||
|
<button
|
||||||
|
className="session-option-item"
|
||||||
|
onClick={() => {
|
||||||
|
setOptionsOpen(false);
|
||||||
|
onRecreateTunnel(session);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Recreate Tunnel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
className="session-option-item danger-text"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -350,6 +373,16 @@ export function SessionCard({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(!isActive && onStart
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "start",
|
||||||
|
label: "Start",
|
||||||
|
icon: "play" as IconName,
|
||||||
|
onClick: () => onStart(session),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(onDelete
|
...(onDelete
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,123 +3,137 @@ import { SessionCard } from "./session-card";
|
|||||||
import type { InstanceHealth } from "../../../api/sessions";
|
import type { InstanceHealth } from "../../../api/sessions";
|
||||||
|
|
||||||
export interface SessionListProps {
|
export interface SessionListProps {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
onOpen?: (session: Session) => void;
|
onOpen?: (session: Session) => void;
|
||||||
onStart?: (session: Session) => void;
|
onStart?: (session: Session) => void;
|
||||||
onStop?: (session: Session) => void;
|
onStop?: (session: Session) => void;
|
||||||
onDelete?: (session: Session) => void;
|
onDelete?: (session: Session) => void;
|
||||||
onRecreateTunnel?: (session: Session) => void;
|
onRecreateTunnel?: (session: Session) => void;
|
||||||
actionBusyId?: string | null;
|
onRename?: (session: Session, newName: string) => void;
|
||||||
tunnelHealth?: Record<string, InstanceHealth>;
|
actionBusyId?: string | null;
|
||||||
showGrouping?: boolean;
|
tunnelHealth?: Record<string, InstanceHealth>;
|
||||||
activeTitle?: string;
|
showGrouping?: boolean;
|
||||||
recentTitle?: string;
|
activeTitle?: string;
|
||||||
maxRecent?: number;
|
recentTitle?: string;
|
||||||
emptyMessage?: string;
|
maxRecent?: number;
|
||||||
|
emptyMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"];
|
const activeStatuses = [
|
||||||
|
"running",
|
||||||
|
"building",
|
||||||
|
"starting",
|
||||||
|
"probing",
|
||||||
|
"pending",
|
||||||
|
"unhealthy",
|
||||||
|
];
|
||||||
const recentStatuses = ["stopped", "error"];
|
const recentStatuses = ["stopped", "error"];
|
||||||
|
|
||||||
export function SessionList({
|
export function SessionList({
|
||||||
sessions,
|
sessions,
|
||||||
onOpen,
|
onOpen,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRecreateTunnel,
|
onRecreateTunnel,
|
||||||
actionBusyId = null,
|
onRename,
|
||||||
tunnelHealth = {},
|
actionBusyId = null,
|
||||||
showGrouping = true,
|
tunnelHealth = {},
|
||||||
activeTitle = "Active Sessions",
|
showGrouping = true,
|
||||||
recentTitle = "Recent Sessions",
|
activeTitle = "Active Sessions",
|
||||||
maxRecent = 5,
|
recentTitle = "Recent Sessions",
|
||||||
emptyMessage = "No sessions",
|
maxRecent = 5,
|
||||||
|
emptyMessage = "No sessions",
|
||||||
}: SessionListProps) {
|
}: SessionListProps) {
|
||||||
const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status));
|
const activeSessions = sessions.filter((s) =>
|
||||||
const recentSessions = sessions
|
activeStatuses.includes(s.status),
|
||||||
.filter((s) => recentStatuses.includes(s.status))
|
);
|
||||||
.slice(0, maxRecent);
|
const recentSessions = sessions
|
||||||
|
.filter((s) => recentStatuses.includes(s.status))
|
||||||
|
.slice(0, maxRecent);
|
||||||
|
|
||||||
if (!showGrouping) {
|
if (!showGrouping) {
|
||||||
return (
|
return (
|
||||||
<div className="sessions-grid">
|
<div className="sessions-grid">
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<p className="muted">{emptyMessage}</p>
|
<p className="muted">{emptyMessage}</p>
|
||||||
) : (
|
) : (
|
||||||
sessions.map((session) => (
|
sessions.map((session) => (
|
||||||
<SessionCard
|
<SessionCard
|
||||||
key={session.id}
|
key={session.id}
|
||||||
session={session}
|
session={session}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRecreateTunnel={onRecreateTunnel}
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
isBusy={actionBusyId === session.id}
|
onRename={onRename}
|
||||||
tunnelHealth={tunnelHealth[session.id] || null}
|
isBusy={actionBusyId === session.id}
|
||||||
/>
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
))
|
/>
|
||||||
)}
|
))
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="session-list">
|
<div className="session-list">
|
||||||
{/* Active Sessions */}
|
{/* Active Sessions */}
|
||||||
<div className="session-group">
|
<div className="session-group">
|
||||||
<div className="session-group-header">
|
<div className="session-group-header">
|
||||||
<h3>{activeTitle}</h3>
|
<h3>{activeTitle}</h3>
|
||||||
{activeSessions.length > 0 && (
|
{activeSessions.length > 0 && (
|
||||||
<span className="badge">{activeSessions.length}</span>
|
<span className="badge">{activeSessions.length}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{activeSessions.length === 0 ? (
|
{activeSessions.length === 0 ? (
|
||||||
<p className="muted">No active sessions</p>
|
<p className="muted">No active sessions</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="sessions-grid">
|
<div className="sessions-grid">
|
||||||
{activeSessions.map((session) => (
|
{activeSessions.map((session) => (
|
||||||
<SessionCard
|
<SessionCard
|
||||||
key={session.id}
|
key={session.id}
|
||||||
session={session}
|
session={session}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRecreateTunnel={onRecreateTunnel}
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
isBusy={actionBusyId === session.id}
|
onRename={onRename}
|
||||||
tunnelHealth={tunnelHealth[session.id] || null}
|
isBusy={actionBusyId === session.id}
|
||||||
/>
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recent Sessions */}
|
{/* Recent Sessions */}
|
||||||
{recentSessions.length > 0 && (
|
{recentSessions.length > 0 && (
|
||||||
<div className="session-group">
|
<div className="session-group">
|
||||||
<div className="session-group-header">
|
<div className="session-group-header">
|
||||||
<h3>{recentTitle}</h3>
|
<h3>{recentTitle}</h3>
|
||||||
<span className="badge">{recentSessions.length}</span>
|
<span className="badge">{recentSessions.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sessions-grid">
|
<div className="sessions-grid">
|
||||||
{recentSessions.map((session) => (
|
{recentSessions.map((session) => (
|
||||||
<SessionCard
|
<SessionCard
|
||||||
key={session.id}
|
key={session.id}
|
||||||
session={session}
|
session={session}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRecreateTunnel={onRecreateTunnel}
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
isBusy={actionBusyId === session.id}
|
onRename={onRename}
|
||||||
tunnelHealth={tunnelHealth[session.id] || null}
|
isBusy={actionBusyId === session.id}
|
||||||
/>
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
useSessionOperations,
|
||||||
|
type Operation,
|
||||||
|
} from "../../../state/session-operations";
|
||||||
|
import { useEventContext } from "../../../state/events";
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
|
interface StepConfig {
|
||||||
|
label: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: StepConfig[] = [
|
||||||
|
{ label: "Created", index: 1 },
|
||||||
|
{ label: "Building", index: 2 },
|
||||||
|
{ label: "Starting", index: 3 },
|
||||||
|
{ label: "Ready", index: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function OperationItem({
|
||||||
|
operation,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
operation: Operation;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}) {
|
||||||
|
const isDone = operation.status === "success" || operation.status === "error";
|
||||||
|
const isError = operation.status === "error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`session-operation-item ${operation.status}`}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="session-operation-header">
|
||||||
|
<div className="session-operation-title">
|
||||||
|
{isError ? (
|
||||||
|
<Icon name="error" size="sm" />
|
||||||
|
) : isDone ? (
|
||||||
|
<Icon name="success" size="sm" />
|
||||||
|
) : (
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
)}
|
||||||
|
<span className="session-operation-name">{operation.displayName}</span>
|
||||||
|
</div>
|
||||||
|
{isDone && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="session-operation-dismiss"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="session-operation-message">{operation.message}</p>
|
||||||
|
<div className="session-operation-steps">
|
||||||
|
{steps.map((step) => {
|
||||||
|
const active = operation.step >= step.index;
|
||||||
|
const current = operation.step === step.index && !isDone;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={step.label}
|
||||||
|
className={`session-operation-step ${active ? "active" : ""} ${current ? "current" : ""}`}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionProgressPanel() {
|
||||||
|
const { operations, updateOperationFromEvent, dismissOperation } =
|
||||||
|
useSessionOperations();
|
||||||
|
const { events } = useEventContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (events.length === 0) return;
|
||||||
|
const latestEvent = events[events.length - 1];
|
||||||
|
updateOperationFromEvent(latestEvent);
|
||||||
|
}, [events, updateOperationFromEvent]);
|
||||||
|
|
||||||
|
const visibleOperations = operations.filter(
|
||||||
|
(op) =>
|
||||||
|
op.status === "pending" ||
|
||||||
|
op.status === "active" ||
|
||||||
|
(op.status === "success" && Date.now() - op.createdAt < 5000) ||
|
||||||
|
op.status === "error",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visibleOperations.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="session-progress-panel" role="region" aria-label="Session operations">
|
||||||
|
<div className="session-progress-panel-header">
|
||||||
|
<span className="session-progress-panel-title">Operations</span>
|
||||||
|
</div>
|
||||||
|
<div className="session-progress-panel-list">
|
||||||
|
{visibleOperations.map((operation) => (
|
||||||
|
<OperationItem
|
||||||
|
key={operation.id}
|
||||||
|
operation={operation}
|
||||||
|
onDismiss={() => dismissOperation(operation.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,7 +8,6 @@ import React, {
|
|||||||
import { Terminal } from "xterm";
|
import { Terminal } from "xterm";
|
||||||
import { FitAddon } from "xterm-addon-fit";
|
import { FitAddon } from "xterm-addon-fit";
|
||||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||||
import { WebglAddon } from "xterm-addon-webgl";
|
|
||||||
import "xterm/css/xterm.css";
|
import "xterm/css/xterm.css";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -310,25 +309,13 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
term.loadAddon(new WebLinksAddon());
|
term.loadAddon(new WebLinksAddon());
|
||||||
|
|
||||||
// Load WebGL renderer for GPU acceleration, fall back to DOM
|
// NOTE: WebGL renderer disabled.
|
||||||
let webglAddon: WebglAddon | null = null;
|
// The WebGL addon causes black-on-black rendering artifacts with
|
||||||
try {
|
// tmux/vim reverse-video (inverse color) sequences on desktop.
|
||||||
webglAddon = new WebglAddon();
|
// Mobile already uses the DOM renderer (WebGL fails there), which
|
||||||
term.loadAddon(webglAddon);
|
// handles these color attributes correctly. The DOM renderer is
|
||||||
webglAddon.onContextLoss(() => {
|
// fast enough for typical terminal workloads.
|
||||||
console.warn("WebGL context lost, falling back to DOM renderer");
|
// See: xterm.js WebGL known issues with reverse video / minimumContrastRatio
|
||||||
try {
|
|
||||||
webglAddon?.dispose();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
webglAddon = null;
|
|
||||||
// Trigger a refit since cell dimensions may differ
|
|
||||||
requestAnimationFrame(() => fitTerminal());
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("WebGL renderer failed to load, using DOM renderer", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = terminalRef.current;
|
const container = terminalRef.current;
|
||||||
|
|
||||||
@@ -585,16 +572,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
window.clearInterval(heartbeatCheckRef.current);
|
window.clearInterval(heartbeatCheckRef.current);
|
||||||
heartbeatCheckRef.current = null;
|
heartbeatCheckRef.current = null;
|
||||||
}
|
}
|
||||||
// Dispose WebGL addon BEFORE the terminal to avoid race with
|
|
||||||
// RenderService.setRenderer accessing a disposed renderer
|
|
||||||
if (webglAddon) {
|
|
||||||
try {
|
|
||||||
webglAddon.dispose();
|
|
||||||
} catch {
|
|
||||||
// Ignore disposal errors from partially torn-down terminal
|
|
||||||
}
|
|
||||||
webglAddon = null;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
term.dispose();
|
term.dispose();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,9 +11,13 @@ import {
|
|||||||
} from "../../../api/sessions";
|
} from "../../../api/sessions";
|
||||||
import type { ToolType } from "../../../api/tool-types";
|
import type { ToolType } from "../../../api/tool-types";
|
||||||
import { CreateSessionForm } from "../session/create-session-form";
|
import { CreateSessionForm } from "../session/create-session-form";
|
||||||
import { listConfigProfiles, type ConfigProfile } from "../../../api/config_profiles";
|
import {
|
||||||
|
listConfigProfiles,
|
||||||
|
type ConfigProfile,
|
||||||
|
} from "../../../api/config-profiles";
|
||||||
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
||||||
import { useEventContext } from "../../../state/events";
|
import { useEventContext } from "../../../state/events";
|
||||||
|
import { useSessions } from "../../../state/sessions";
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
@@ -34,6 +38,7 @@ export const InstanceList = ({
|
|||||||
toolTypes,
|
toolTypes,
|
||||||
}: InstanceListProps) => {
|
}: InstanceListProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { refreshSessions } = useSessions();
|
||||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
@@ -105,6 +110,7 @@ export const InstanceList = ({
|
|||||||
const handleCreateSuccess = async () => {
|
const handleCreateSuccess = async () => {
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
|
await refreshSessions();
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadConfigProfiles = useCallback(
|
const loadConfigProfiles = useCallback(
|
||||||
@@ -193,6 +199,7 @@ export const InstanceList = ({
|
|||||||
await deleteInstance(projectId, repoId, instanceId);
|
await deleteInstance(projectId, repoId, instanceId);
|
||||||
// Update state immediately instead of reloading
|
// Update state immediately instead of reloading
|
||||||
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
|
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
|
||||||
|
await refreshSessions();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to delete instance");
|
setError("Failed to delete instance");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -242,15 +249,7 @@ export const InstanceList = ({
|
|||||||
) : (
|
) : (
|
||||||
<div className="instance-grid">
|
<div className="instance-grid">
|
||||||
{instances.map((instance) => (
|
{instances.map((instance) => (
|
||||||
<div
|
<div key={instance.id} className="instance-card">
|
||||||
key={instance.id}
|
|
||||||
className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}
|
|
||||||
>
|
|
||||||
{busyInstanceId === instance.id && (
|
|
||||||
<div className="instance-busy-overlay">
|
|
||||||
<Icon name="loading" size="md" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="instance-info">
|
<div className="instance-info">
|
||||||
<div className="instance-name">{instance.display_name}</div>
|
<div className="instance-name">{instance.display_name}</div>
|
||||||
<div className="instance-meta">
|
<div className="instance-meta">
|
||||||
@@ -282,8 +281,8 @@ export const InstanceList = ({
|
|||||||
? instance.url
|
? instance.url
|
||||||
: `${API_BASE_URL}${instance.url}`
|
: `${API_BASE_URL}${instance.url}`
|
||||||
}
|
}
|
||||||
target="_blank"
|
target={`instance-${instance.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
>
|
>
|
||||||
<Icon name="external" size="sm" />
|
<Icon name="external" size="sm" />
|
||||||
@@ -307,120 +306,117 @@ export const InstanceList = ({
|
|||||||
)}
|
)}
|
||||||
{instance.status !== "running" && (
|
{instance.status !== "running" && (
|
||||||
<>
|
<>
|
||||||
{profileSelectInstanceId === instance.id ? (
|
{profileSelectInstanceId === instance.id ? (
|
||||||
<div className="inline-profile-select">
|
<div className="inline-profile-select">
|
||||||
<select
|
<select
|
||||||
value={selectedProfileForAction}
|
value={selectedProfileForAction}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSelectedProfileForAction(e.target.value)
|
setSelectedProfileForAction(e.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="">Default (none)</option>
|
<option value="">Default (none)</option>
|
||||||
{configProfiles.map((p) => (
|
{configProfiles.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.name}
|
{p.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div
|
<div
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: "0.25rem",
|
|
||||||
marginTop: "0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sshKeys.map((key) => (
|
|
||||||
<label
|
|
||||||
key={key.id}
|
|
||||||
className="checkbox-label"
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.75rem",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexWrap: "wrap",
|
||||||
gap: "0.25rem",
|
gap: "0.25rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
{sshKeys.map((key) => (
|
||||||
type="checkbox"
|
<label
|
||||||
checked={selectedSshKeyIdsForAction.includes(
|
key={key.id}
|
||||||
key.id,
|
className="checkbox-label"
|
||||||
)}
|
style={{
|
||||||
onChange={(e) => {
|
fontSize: "0.75rem",
|
||||||
if (e.target.checked) {
|
display: "flex",
|
||||||
setSelectedSshKeyIdsForAction(
|
alignItems: "center",
|
||||||
(prev) => [...prev, key.id],
|
gap: "0.25rem",
|
||||||
);
|
}}
|
||||||
} else {
|
>
|
||||||
setSelectedSshKeyIdsForAction(
|
<input
|
||||||
(prev) =>
|
type="checkbox"
|
||||||
prev.filter(
|
checked={selectedSshKeyIdsForAction.includes(
|
||||||
(id) =>
|
key.id,
|
||||||
id !== key.id,
|
)}
|
||||||
),
|
onChange={(e) => {
|
||||||
);
|
if (e.target.checked) {
|
||||||
}
|
setSelectedSshKeyIdsForAction((prev) => [
|
||||||
}}
|
...prev,
|
||||||
/>
|
key.id,
|
||||||
{key.name}
|
]);
|
||||||
</label>
|
} else {
|
||||||
))}
|
setSelectedSshKeyIdsForAction((prev) =>
|
||||||
</div>
|
prev.filter((id) => id !== key.id),
|
||||||
<button
|
);
|
||||||
className="primary-button small"
|
}
|
||||||
onClick={() =>
|
}}
|
||||||
void handleStart(
|
/>
|
||||||
instance.id,
|
{key.name}
|
||||||
selectedProfileForAction || undefined,
|
</label>
|
||||||
selectedSshKeyIdsForAction.length > 0
|
))}
|
||||||
? selectedSshKeyIdsForAction
|
</div>
|
||||||
: undefined,
|
<button
|
||||||
)
|
className="primary-button small"
|
||||||
}
|
onClick={() =>
|
||||||
type="button"
|
void handleStart(
|
||||||
disabled={busyInstanceId === instance.id}
|
instance.id,
|
||||||
>
|
selectedProfileForAction || undefined,
|
||||||
<Icon name="play" size="sm" />
|
selectedSshKeyIdsForAction.length > 0
|
||||||
Start
|
? selectedSshKeyIdsForAction
|
||||||
</button>
|
: undefined,
|
||||||
<button
|
)
|
||||||
className="ghost-button small"
|
}
|
||||||
onClick={() => {
|
type="button"
|
||||||
setProfileSelectInstanceId(null);
|
disabled={busyInstanceId === instance.id}
|
||||||
setSelectedProfileForAction("");
|
>
|
||||||
setSelectedSshKeyIdsForAction([]);
|
<Icon name="play" size="sm" />
|
||||||
}}
|
Start
|
||||||
type="button"
|
</button>
|
||||||
disabled={busyInstanceId === instance.id}
|
<button
|
||||||
>
|
className="ghost-button small"
|
||||||
Cancel
|
onClick={() => {
|
||||||
</button>
|
setProfileSelectInstanceId(null);
|
||||||
</div>
|
setSelectedProfileForAction("");
|
||||||
) : (
|
setSelectedSshKeyIdsForAction([]);
|
||||||
<button
|
}}
|
||||||
className="secondary-button small"
|
type="button"
|
||||||
onClick={() => {
|
disabled={busyInstanceId === instance.id}
|
||||||
const toolType = toolTypes.find(
|
>
|
||||||
(t) => t.id === instance.tool_type_id,
|
Cancel
|
||||||
);
|
</button>
|
||||||
if (toolType) {
|
</div>
|
||||||
void loadConfigProfiles(toolType.id);
|
) : (
|
||||||
}
|
<button
|
||||||
setProfileSelectInstanceId(instance.id);
|
className="secondary-button small"
|
||||||
setSelectedProfileForAction(
|
onClick={() => {
|
||||||
instance.selected_config_profile_id || "",
|
const toolType = toolTypes.find(
|
||||||
);
|
(t) => t.id === instance.tool_type_id,
|
||||||
setSelectedSshKeyIdsForAction(
|
);
|
||||||
instance.ssh_key_ids || [],
|
if (toolType) {
|
||||||
);
|
void loadConfigProfiles(toolType.id);
|
||||||
}}
|
}
|
||||||
type="button"
|
setProfileSelectInstanceId(instance.id);
|
||||||
disabled={busyInstanceId === instance.id}
|
setSelectedProfileForAction(
|
||||||
>
|
instance.selected_config_profile_id || "",
|
||||||
<Icon name="play" size="sm" />
|
);
|
||||||
Start
|
setSelectedSshKeyIdsForAction(
|
||||||
</button>
|
instance.ssh_key_ids || [],
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{instance.status === "running" && (
|
{instance.status === "running" && (
|
||||||
@@ -455,119 +451,116 @@ export const InstanceList = ({
|
|||||||
<Icon name="stop" size="sm" />
|
<Icon name="stop" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{profileSelectInstanceId === instance.id ? (
|
{profileSelectInstanceId === instance.id ? (
|
||||||
<div className="inline-profile-select">
|
<div className="inline-profile-select">
|
||||||
<select
|
<select
|
||||||
value={selectedProfileForAction}
|
value={selectedProfileForAction}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSelectedProfileForAction(e.target.value)
|
setSelectedProfileForAction(e.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="">Default (none)</option>
|
<option value="">Default (none)</option>
|
||||||
{configProfiles.map((p) => (
|
{configProfiles.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.name}
|
{p.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div
|
<div
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: "0.25rem",
|
|
||||||
marginTop: "0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sshKeys.map((key) => (
|
|
||||||
<label
|
|
||||||
key={key.id}
|
|
||||||
className="checkbox-label"
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.75rem",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexWrap: "wrap",
|
||||||
gap: "0.25rem",
|
gap: "0.25rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
{sshKeys.map((key) => (
|
||||||
type="checkbox"
|
<label
|
||||||
checked={selectedSshKeyIdsForAction.includes(
|
key={key.id}
|
||||||
key.id,
|
className="checkbox-label"
|
||||||
)}
|
style={{
|
||||||
onChange={(e) => {
|
fontSize: "0.75rem",
|
||||||
if (e.target.checked) {
|
display: "flex",
|
||||||
setSelectedSshKeyIdsForAction(
|
alignItems: "center",
|
||||||
(prev) => [...prev, key.id],
|
gap: "0.25rem",
|
||||||
);
|
}}
|
||||||
} else {
|
>
|
||||||
setSelectedSshKeyIdsForAction(
|
<input
|
||||||
(prev) =>
|
type="checkbox"
|
||||||
prev.filter(
|
checked={selectedSshKeyIdsForAction.includes(
|
||||||
(id) =>
|
key.id,
|
||||||
id !== key.id,
|
)}
|
||||||
),
|
onChange={(e) => {
|
||||||
);
|
if (e.target.checked) {
|
||||||
}
|
setSelectedSshKeyIdsForAction((prev) => [
|
||||||
}}
|
...prev,
|
||||||
/>
|
key.id,
|
||||||
{key.name}
|
]);
|
||||||
</label>
|
} else {
|
||||||
))}
|
setSelectedSshKeyIdsForAction((prev) =>
|
||||||
</div>
|
prev.filter((id) => id !== key.id),
|
||||||
<button
|
);
|
||||||
className="primary-button small"
|
}
|
||||||
onClick={() =>
|
}}
|
||||||
void handleRestart(
|
/>
|
||||||
instance.id,
|
{key.name}
|
||||||
selectedProfileForAction || undefined,
|
</label>
|
||||||
selectedSshKeyIdsForAction.length > 0
|
))}
|
||||||
? selectedSshKeyIdsForAction
|
</div>
|
||||||
: undefined,
|
<button
|
||||||
)
|
className="primary-button small"
|
||||||
}
|
onClick={() =>
|
||||||
type="button"
|
void handleRestart(
|
||||||
disabled={busyInstanceId === instance.id}
|
instance.id,
|
||||||
>
|
selectedProfileForAction || undefined,
|
||||||
<Icon name="refresh" size="sm" />
|
selectedSshKeyIdsForAction.length > 0
|
||||||
Restart
|
? selectedSshKeyIdsForAction
|
||||||
</button>
|
: undefined,
|
||||||
<button
|
)
|
||||||
className="ghost-button small"
|
}
|
||||||
onClick={() => {
|
type="button"
|
||||||
setProfileSelectInstanceId(null);
|
disabled={busyInstanceId === instance.id}
|
||||||
setSelectedProfileForAction("");
|
>
|
||||||
setSelectedSshKeyIdsForAction([]);
|
<Icon name="refresh" size="sm" />
|
||||||
}}
|
Restart
|
||||||
type="button"
|
</button>
|
||||||
disabled={busyInstanceId === instance.id}
|
<button
|
||||||
>
|
className="ghost-button small"
|
||||||
Cancel
|
onClick={() => {
|
||||||
</button>
|
setProfileSelectInstanceId(null);
|
||||||
</div>
|
setSelectedProfileForAction("");
|
||||||
) : (
|
setSelectedSshKeyIdsForAction([]);
|
||||||
<button
|
}}
|
||||||
className="ghost-button small"
|
type="button"
|
||||||
onClick={() => {
|
disabled={busyInstanceId === instance.id}
|
||||||
const toolType = toolTypes.find(
|
>
|
||||||
(t) => t.id === instance.tool_type_id,
|
Cancel
|
||||||
);
|
</button>
|
||||||
if (toolType) {
|
</div>
|
||||||
void loadConfigProfiles(toolType.id);
|
) : (
|
||||||
}
|
<button
|
||||||
setProfileSelectInstanceId(instance.id);
|
className="ghost-button small"
|
||||||
setSelectedProfileForAction(
|
onClick={() => {
|
||||||
instance.selected_config_profile_id || "",
|
const toolType = toolTypes.find(
|
||||||
);
|
(t) => t.id === instance.tool_type_id,
|
||||||
setSelectedSshKeyIdsForAction(
|
);
|
||||||
instance.ssh_key_ids || [],
|
if (toolType) {
|
||||||
);
|
void loadConfigProfiles(toolType.id);
|
||||||
}}
|
}
|
||||||
type="button"
|
setProfileSelectInstanceId(instance.id);
|
||||||
disabled={busyInstanceId === instance.id}
|
setSelectedProfileForAction(
|
||||||
>
|
instance.selected_config_profile_id || "",
|
||||||
<Icon name="refresh" size="sm" />
|
);
|
||||||
</button>
|
setSelectedSshKeyIdsForAction(
|
||||||
)}
|
instance.ssh_key_ids || [],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { extractErrorMessage } from "../../../utils/errors";
|
|||||||
import {
|
import {
|
||||||
compileToolDefinition,
|
compileToolDefinition,
|
||||||
type ToolDefinitionManifest,
|
type ToolDefinitionManifest,
|
||||||
} from "../../../api/tool_definitions";
|
} from "../../../api/tool-definitions";
|
||||||
|
|
||||||
interface PackageEntry {
|
interface PackageEntry {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Icon } from "../../icon";
|
import { Icon } from "../../icon";
|
||||||
import { listToolTypes, type ToolType } from "../../../api/tool-types";
|
import { listToolTypes, type ToolType } from "../../../api/tool-types";
|
||||||
import { listConfigProfiles, type ConfigProfile } from "../../../api/config_profiles";
|
import {
|
||||||
|
listConfigProfiles,
|
||||||
|
type ConfigProfile,
|
||||||
|
} from "../../../api/config-profiles";
|
||||||
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
||||||
|
import { useSessions } from "../../../state/sessions";
|
||||||
|
import { useSessionOperations } from "../../../state/session-operations";
|
||||||
import type { Workspace } from "../../../types/workspace";
|
import type { Workspace } from "../../../types/workspace";
|
||||||
import type { ToolInstance } from "../../../api/sessions";
|
import type { ToolInstance } from "../../../api/sessions";
|
||||||
|
|
||||||
@@ -19,6 +24,8 @@ export function ToolStarter({
|
|||||||
onStarted,
|
onStarted,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: ToolStarterProps) {
|
}: ToolStarterProps) {
|
||||||
|
const { addOrUpdateSession } = useSessions();
|
||||||
|
const { startOperation } = useSessionOperations();
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [toolTypesLoading, setToolTypesLoading] = useState(true);
|
const [toolTypesLoading, setToolTypesLoading] = useState(true);
|
||||||
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
|
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
|
||||||
@@ -33,6 +40,8 @@ export function ToolStarter({
|
|||||||
const [sshKeysLoading, setSshKeysLoading] = useState(true);
|
const [sshKeysLoading, setSshKeysLoading] = useState(true);
|
||||||
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [displayName, setDisplayName] = useState(workspace.name);
|
||||||
|
const [nameEdited, setNameEdited] = useState(false);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -114,12 +123,14 @@ export function ToolStarter({
|
|||||||
setStarting(true);
|
setStarting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { createInstance, startInstance } = await import("../../../api/sessions");
|
const { createInstance, startInstance } = await import(
|
||||||
|
"../../../api/sessions"
|
||||||
|
);
|
||||||
const instance = await createInstance(
|
const instance = await createInstance(
|
||||||
workspace.project_id,
|
workspace.project_id,
|
||||||
workspace.repo_id,
|
workspace.repo_id,
|
||||||
selectedToolTypeId,
|
selectedToolTypeId,
|
||||||
workspace.name,
|
displayName.trim() || undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -134,13 +145,37 @@ export function ToolStarter({
|
|||||||
selectedProfileId || undefined,
|
selectedProfileId || undefined,
|
||||||
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
|
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
|
||||||
);
|
);
|
||||||
|
addOrUpdateSession({
|
||||||
|
id: instance.id,
|
||||||
|
display_name: instance.display_name,
|
||||||
|
tool_type_name: instance.tool_type_name,
|
||||||
|
tool_icon: "code",
|
||||||
|
tool_type_interfaces: instance.tool_type_interfaces || [],
|
||||||
|
repository_name: workspace.repo_name,
|
||||||
|
repository_id: workspace.repo_id,
|
||||||
|
project_name: workspace.project_name,
|
||||||
|
project_id: workspace.project_id,
|
||||||
|
workspace_name: workspace.name,
|
||||||
|
status: instance.status || "pending",
|
||||||
|
url: instance.url || null,
|
||||||
|
});
|
||||||
|
startOperation("create", instance.id, instance.display_name);
|
||||||
onStarted(instance);
|
onStarted(instance);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to start tool");
|
setError(err instanceof Error ? err.message : "Failed to start tool");
|
||||||
} finally {
|
} finally {
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
}
|
}
|
||||||
}, [selectedToolTypeId, selectedProfileId, workspace, onStarted]);
|
}, [
|
||||||
|
selectedToolTypeId,
|
||||||
|
selectedProfileId,
|
||||||
|
displayName,
|
||||||
|
workspace,
|
||||||
|
onStarted,
|
||||||
|
toolTypes,
|
||||||
|
addOrUpdateSession,
|
||||||
|
startOperation,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tool-starter">
|
<div className="tool-starter">
|
||||||
@@ -170,8 +205,13 @@ export function ToolStarter({
|
|||||||
id="tool-type"
|
id="tool-type"
|
||||||
value={selectedToolTypeId}
|
value={selectedToolTypeId}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSelectedToolTypeId(e.target.value);
|
const toolId = e.target.value;
|
||||||
|
setSelectedToolTypeId(toolId);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const tt = toolTypes.find((t) => t.id === toolId);
|
||||||
|
if (tt && !nameEdited) {
|
||||||
|
setDisplayName(`${workspace.name} ${tt.display_name}`);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={toolTypesLoading || starting}
|
disabled={toolTypesLoading || starting}
|
||||||
>
|
>
|
||||||
@@ -187,6 +227,22 @@ export function ToolStarter({
|
|||||||
{toolTypesError && <span className="error-text">{toolTypesError}</span>}
|
{toolTypesError && <span className="error-text">{toolTypesError}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Session Name */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="session-name">Session Name</label>
|
||||||
|
<input
|
||||||
|
id="session-name"
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDisplayName(e.target.value);
|
||||||
|
setNameEdited(true);
|
||||||
|
}}
|
||||||
|
placeholder="My dev environment"
|
||||||
|
disabled={starting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Config Profile */}
|
{/* Config Profile */}
|
||||||
{selectedToolTypeId && (
|
{selectedToolTypeId && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -43,8 +43,8 @@ export function WorkspaceInstanceChips({
|
|||||||
{inst.status === "running" && inst.url && (
|
{inst.status === "running" && inst.url && (
|
||||||
<a
|
<a
|
||||||
href={inst.url}
|
href={inst.url}
|
||||||
target="_blank"
|
target={`instance-${inst.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
↗
|
↗
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
DotsSixVertical,
|
DotsSixVertical,
|
||||||
|
DotsThreeVertical,
|
||||||
Bell,
|
Bell,
|
||||||
CaretDown,
|
CaretDown,
|
||||||
CaretRight,
|
CaretRight,
|
||||||
@@ -81,6 +82,7 @@ export type IconName =
|
|||||||
| "terminal"
|
| "terminal"
|
||||||
| "arrow-left"
|
| "arrow-left"
|
||||||
| "drag"
|
| "drag"
|
||||||
|
| "more"
|
||||||
| "bell"
|
| "bell"
|
||||||
| "chevron-down"
|
| "chevron-down"
|
||||||
| "chevron-right";
|
| "chevron-right";
|
||||||
@@ -132,6 +134,7 @@ const iconMap: Record<
|
|||||||
terminal: Terminal,
|
terminal: Terminal,
|
||||||
"arrow-left": ArrowLeft,
|
"arrow-left": ArrowLeft,
|
||||||
drag: DotsSixVertical,
|
drag: DotsSixVertical,
|
||||||
|
more: DotsThreeVertical,
|
||||||
bell: Bell,
|
bell: Bell,
|
||||||
"chevron-down": CaretDown,
|
"chevron-down": CaretDown,
|
||||||
"chevron-right": CaretRight,
|
"chevron-right": CaretRight,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
stopInstance,
|
stopInstance,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
startInstance,
|
startInstance,
|
||||||
recreateInstanceTunnel,
|
recreateInstanceTunnel,
|
||||||
|
renameInstance,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { Session } from "../api/sessions";
|
import type { Session } from "../api/sessions";
|
||||||
|
import { useSessions } from "../state/sessions";
|
||||||
|
import { useSessionOperations } from "../state/session-operations";
|
||||||
|
|
||||||
interface UseInstanceActionsOptions {
|
interface UseInstanceActionsOptions {
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
@@ -21,6 +24,7 @@ interface UseInstanceActionsReturn {
|
|||||||
handleDelete: (session: Session) => Promise<void>;
|
handleDelete: (session: Session) => Promise<void>;
|
||||||
handleForceDelete: (session: Session) => Promise<void>;
|
handleForceDelete: (session: Session) => Promise<void>;
|
||||||
handleRecreateTunnel: (session: Session) => Promise<void>;
|
handleRecreateTunnel: (session: Session) => Promise<void>;
|
||||||
|
handleRename: (session: Session, newName: string) => Promise<void>;
|
||||||
clearDirtyDelete: () => void;
|
clearDirtyDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,28 +32,41 @@ export function useInstanceActions(
|
|||||||
options: UseInstanceActionsOptions,
|
options: UseInstanceActionsOptions,
|
||||||
): UseInstanceActionsReturn {
|
): UseInstanceActionsReturn {
|
||||||
const { onRefresh } = options;
|
const { onRefresh } = options;
|
||||||
|
const { removeSession } = useSessions();
|
||||||
|
const { startOperation, completeOperation } = useSessionOperations();
|
||||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
|
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
|
const tabRefs = useRef<Map<string, Window | null>>(new Map());
|
||||||
|
|
||||||
const handleOpen = useCallback((session: Session) => {
|
const handleOpen = useCallback((session: Session) => {
|
||||||
|
const key = session.id;
|
||||||
|
const existing = tabRefs.current.get(key);
|
||||||
|
if (existing && !existing.closed) {
|
||||||
|
existing.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: string;
|
||||||
if (session.url) {
|
if (session.url) {
|
||||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
url = session.url;
|
||||||
return;
|
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
||||||
|
url = `/instances/${session.id}/terminal`;
|
||||||
|
} else {
|
||||||
|
url = `/projects/${session.project_id}`;
|
||||||
}
|
}
|
||||||
if (session.tool_type_interfaces?.includes("terminal")) {
|
|
||||||
window.open(`/instances/${session.id}/terminal`, "_blank", "noopener,noreferrer");
|
const w = window.open(url, `session-${session.id}`);
|
||||||
return;
|
tabRefs.current.set(key, w);
|
||||||
}
|
|
||||||
window.open(`/projects/${session.project_id}`, "_blank", "noopener,noreferrer");
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStart = useCallback(
|
const handleStart = useCallback(
|
||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
if (loadingSessionId === session.id) return;
|
if (loadingSessionId === session.id) return;
|
||||||
setLoadingSessionId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
|
startOperation("start", session.id, session.display_name);
|
||||||
try {
|
try {
|
||||||
await startInstance(
|
await startInstance(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -58,18 +75,19 @@ export function useInstanceActions(
|
|||||||
);
|
);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
completeOperation(session.id, "start", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadingSessionId, onRefresh],
|
[loadingSessionId, onRefresh, startOperation, completeOperation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStop = useCallback(
|
const handleStop = useCallback(
|
||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
if (loadingSessionId === session.id) return;
|
if (loadingSessionId === session.id) return;
|
||||||
setLoadingSessionId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
|
startOperation("stop", session.id, session.display_name);
|
||||||
try {
|
try {
|
||||||
await stopInstance(
|
await stopInstance(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -78,18 +96,19 @@ export function useInstanceActions(
|
|||||||
);
|
);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
completeOperation(session.id, "stop", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadingSessionId, onRefresh],
|
[loadingSessionId, onRefresh, startOperation, completeOperation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
if (loadingSessionId === session.id) return;
|
if (loadingSessionId === session.id) return;
|
||||||
setLoadingSessionId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
|
startOperation("delete", session.id, session.display_name);
|
||||||
try {
|
try {
|
||||||
await deleteInstance(
|
await deleteInstance(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -98,8 +117,10 @@ export function useInstanceActions(
|
|||||||
);
|
);
|
||||||
setDirtyDeleteSession(null);
|
setDirtyDeleteSession(null);
|
||||||
setDirtyDeleteFiles([]);
|
setDirtyDeleteFiles([]);
|
||||||
|
removeSession(session.id);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
completeOperation(session.id, "delete", "error");
|
||||||
const axiosError = error as {
|
const axiosError = error as {
|
||||||
response?: {
|
response?: {
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -118,13 +139,20 @@ export function useInstanceActions(
|
|||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadingSessionId, onRefresh],
|
[
|
||||||
|
loadingSessionId,
|
||||||
|
onRefresh,
|
||||||
|
removeSession,
|
||||||
|
startOperation,
|
||||||
|
completeOperation,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleForceDelete = useCallback(
|
const handleForceDelete = useCallback(
|
||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
if (loadingSessionId === session.id) return;
|
if (loadingSessionId === session.id) return;
|
||||||
setLoadingSessionId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
|
startOperation("delete", session.id, session.display_name);
|
||||||
try {
|
try {
|
||||||
await deleteInstance(
|
await deleteInstance(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -134,20 +162,28 @@ export function useInstanceActions(
|
|||||||
);
|
);
|
||||||
setDirtyDeleteSession(null);
|
setDirtyDeleteSession(null);
|
||||||
setDirtyDeleteFiles([]);
|
setDirtyDeleteFiles([]);
|
||||||
|
removeSession(session.id);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
completeOperation(session.id, "delete", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadingSessionId, onRefresh],
|
[
|
||||||
|
loadingSessionId,
|
||||||
|
onRefresh,
|
||||||
|
removeSession,
|
||||||
|
startOperation,
|
||||||
|
completeOperation,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRecreateTunnel = useCallback(
|
const handleRecreateTunnel = useCallback(
|
||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
if (loadingSessionId === session.id) return;
|
if (loadingSessionId === session.id) return;
|
||||||
setLoadingSessionId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
|
startOperation("recreate-tunnel", session.id, session.display_name);
|
||||||
try {
|
try {
|
||||||
await recreateInstanceTunnel(
|
await recreateInstanceTunnel(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -155,16 +191,39 @@ export function useInstanceActions(
|
|||||||
session.id,
|
session.id,
|
||||||
);
|
);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
|
completeOperation(session.id, "recreate-tunnel", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
(err as { response?: { data?: { detail?: string } } })?.response?.data
|
(err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||||
?.detail || "Failed to recreate tunnel";
|
?.detail || "Failed to recreate tunnel";
|
||||||
|
completeOperation(session.id, "recreate-tunnel", "error", message);
|
||||||
alert(message);
|
alert(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadingSessionId, onRefresh],
|
[loadingSessionId, onRefresh, startOperation, completeOperation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRename = useCallback(
|
||||||
|
async (session: Session, newName: string) => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await renameInstance(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id,
|
||||||
|
newName.trim(),
|
||||||
|
);
|
||||||
|
await onRefresh();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onRefresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearDirtyDelete = useCallback(() => {
|
const clearDirtyDelete = useCallback(() => {
|
||||||
@@ -182,6 +241,7 @@ export function useInstanceActions(
|
|||||||
handleDelete,
|
handleDelete,
|
||||||
handleForceDelete,
|
handleForceDelete,
|
||||||
handleRecreateTunnel,
|
handleRecreateTunnel,
|
||||||
|
handleRename,
|
||||||
clearDirtyDelete,
|
clearDirtyDelete,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,345 @@
|
|||||||
|
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 [instanceInfo, setInstanceInfo] = useState<{
|
||||||
|
display_name: string;
|
||||||
|
workspace_name?: string | null;
|
||||||
|
tool_type_name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
setActiveSessionId,
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
renameSession,
|
||||||
|
resetSession,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useTerminalSessions(instanceId ?? "");
|
||||||
|
|
||||||
|
// Fetch instance details for tab title
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const { getUserSessions } = await import("../api/sessions");
|
||||||
|
const allSessions = await getUserSessions();
|
||||||
|
const match = allSessions.find((s) => s.id === instanceId);
|
||||||
|
if (match) {
|
||||||
|
setInstanceInfo({
|
||||||
|
display_name: match.display_name,
|
||||||
|
workspace_name: match.workspace_name,
|
||||||
|
tool_type_name: match.tool_type_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
// Auto-create default session
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||||
|
void createSession("Session 1");
|
||||||
|
}
|
||||||
|
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||||
|
|
||||||
|
// Update document title based on active terminal session
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) {
|
||||||
|
document.title = "Terminal";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = sessions.find((s) => s.id === activeSessionId);
|
||||||
|
const baseName = instanceInfo
|
||||||
|
? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}`
|
||||||
|
: `Instance ${instanceId.slice(0, 8)}`;
|
||||||
|
if (sessions.length <= 1) {
|
||||||
|
document.title = baseName;
|
||||||
|
} else {
|
||||||
|
const sessionName = active?.name ?? "Session";
|
||||||
|
document.title = `${baseName} ${sessionName}`;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.title = "Headquarter";
|
||||||
|
};
|
||||||
|
}, [instanceId, activeSessionId, sessions, instanceInfo]);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
export interface UseWorkspaceFilesResult {
|
export interface UseWorkspaceFilesResult {
|
||||||
entries: FileEntry[];
|
entries: FileEntry[];
|
||||||
content: string | null;
|
content: string | null;
|
||||||
|
currentPath: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
loadFile: (path: string) => Promise<void>;
|
loadFile: (path: string) => Promise<void>;
|
||||||
saveFile: (path: string, content: string, message?: string) => Promise<void>;
|
saveFile: (path: string, content: string, message?: string) => Promise<void>;
|
||||||
|
navigateTo: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaceFiles(
|
export function useWorkspaceFiles(
|
||||||
@@ -23,6 +25,7 @@ export function useWorkspaceFiles(
|
|||||||
): UseWorkspaceFilesResult {
|
): UseWorkspaceFilesResult {
|
||||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||||
const [content, setContent] = useState<string | null>(null);
|
const [content, setContent] = useState<string | null>(null);
|
||||||
|
const [currentPath, setCurrentPath] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -30,14 +33,19 @@ export function useWorkspaceFiles(
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await listWorkspaceFiles(workspaceId);
|
const data = await listWorkspaceFiles(workspaceId, currentPath);
|
||||||
setEntries(data);
|
setEntries(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load files");
|
setError(err instanceof Error ? err.message : "Failed to load files");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [workspaceId]);
|
}, [workspaceId, currentPath]);
|
||||||
|
|
||||||
|
const navigateTo = useCallback((path: string) => {
|
||||||
|
setCurrentPath(path);
|
||||||
|
setContent(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadFile = useCallback(
|
const loadFile = useCallback(
|
||||||
async (path: string) => {
|
async (path: string) => {
|
||||||
@@ -64,5 +72,15 @@ export function useWorkspaceFiles(
|
|||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
return { entries, content, loading, error, refresh, loadFile, saveFile };
|
return {
|
||||||
|
entries,
|
||||||
|
content,
|
||||||
|
currentPath,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
loadFile,
|
||||||
|
saveFile,
|
||||||
|
navigateTo,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-10
@@ -5,16 +5,26 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
import { AppRouter } from "./router";
|
import { AppRouter } from "./router";
|
||||||
import { AuthProvider } from "./state/auth";
|
import { AuthProvider } from "./state/auth";
|
||||||
import { SessionsProvider } from "./state/sessions";
|
import { SessionsProvider } from "./state/sessions";
|
||||||
import "./styles.css";
|
import "./styles/tokens.css";
|
||||||
|
import "./styles/global.css";
|
||||||
|
import "./styles/utilities.css";
|
||||||
|
import "./styles/syntax-highlight.css";
|
||||||
|
import "./styles/pages/git-history.css";
|
||||||
|
import "./styles/pages/repo-workspace.css";
|
||||||
|
import "./styles/pages/projects.css";
|
||||||
|
import "./styles/pages/sessions.css";
|
||||||
|
import "./styles/pages/ssh-keys.css";
|
||||||
|
import "./styles/pages/workspace-detail.css";
|
||||||
|
import "./styles/pages/workspaces.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SessionsProvider>
|
<SessionsProvider>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</SessionsProvider>
|
</SessionsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { HomePage } from "./dashboard";
|
import { HomePage } from "./DashboardPage";
|
||||||
|
import { SessionsProvider } from "../state/sessions";
|
||||||
|
import { SessionOperationsProvider } from "../state/session-operations";
|
||||||
|
|
||||||
const mockDashboard = vi.fn();
|
const mockDashboard = vi.fn();
|
||||||
const mockSessions = vi.fn();
|
const mockSessions = vi.fn();
|
||||||
@@ -10,72 +13,87 @@ const mockProjects = vi.fn();
|
|||||||
const mockRepos = vi.fn();
|
const mockRepos = vi.fn();
|
||||||
|
|
||||||
vi.mock("../api/dashboard", () => ({
|
vi.mock("../api/dashboard", () => ({
|
||||||
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
|
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../api/sessions", () => ({
|
vi.mock("../api/sessions", () => ({
|
||||||
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
||||||
createInstance: vi.fn(),
|
createInstance: vi.fn(),
|
||||||
startInstance: vi.fn(),
|
startInstance: vi.fn(),
|
||||||
stopInstance: vi.fn(),
|
stopInstance: vi.fn(),
|
||||||
deleteInstance: vi.fn(),
|
deleteInstance: vi.fn(),
|
||||||
recreateInstanceTunnel: vi.fn()
|
recreateInstanceTunnel: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../api/projects", () => ({
|
vi.mock("../api/projects", () => ({
|
||||||
listProjects: (...args: unknown[]) => mockProjects(...args)
|
listProjects: (...args: unknown[]) => mockProjects(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../api/git-repositories", () => ({
|
vi.mock("../api/git-repositories", () => ({
|
||||||
listRepositories: (...args: unknown[]) => mockRepos(...args)
|
listRepositories: (...args: unknown[]) => mockRepos(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../api/tool-types", () => ({
|
vi.mock("../api/tool-types", () => ({
|
||||||
listToolTypes: vi.fn().mockResolvedValue([])
|
listToolTypes: vi.fn().mockResolvedValue([]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("HomePage", () => {
|
describe("HomePage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockDashboard.mockReset();
|
mockDashboard.mockReset();
|
||||||
mockSessions.mockReset();
|
mockSessions.mockReset();
|
||||||
mockProjects.mockReset();
|
mockProjects.mockReset();
|
||||||
mockRepos.mockReset();
|
mockRepos.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows overview sections", async () => {
|
it("shows overview sections", async () => {
|
||||||
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
|
mockDashboard.mockResolvedValue({
|
||||||
mockSessions.mockResolvedValue([]);
|
projects: 1,
|
||||||
mockProjects.mockResolvedValue([]);
|
repositories: 2,
|
||||||
mockRepos.mockResolvedValue([]);
|
sshKeys: 3,
|
||||||
|
recentActivity: [],
|
||||||
|
});
|
||||||
|
mockSessions.mockResolvedValue([]);
|
||||||
|
mockProjects.mockResolvedValue([]);
|
||||||
|
mockRepos.mockResolvedValue([]);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<HomePage />
|
<SessionsProvider>
|
||||||
</MemoryRouter>
|
<SessionOperationsProvider>
|
||||||
);
|
<HomePage />
|
||||||
|
</SessionOperationsProvider>
|
||||||
|
</SessionsProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("Available projects")).toBeInTheDocument();
|
expect(screen.getByText("Workspaces")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows retry action when home load fails", async () => {
|
it("shows retry action when home load fails", async () => {
|
||||||
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
||||||
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
||||||
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<HomePage />
|
<SessionsProvider>
|
||||||
</MemoryRouter>
|
<SessionOperationsProvider>
|
||||||
);
|
<HomePage />
|
||||||
|
</SessionOperationsProvider>
|
||||||
|
</SessionsProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
|
expect(
|
||||||
});
|
screen.getByText("Unable to load your workspace overview."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
|
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,15 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||||
import {
|
import { checkInstanceHealth, type InstanceHealth } from "../api/sessions";
|
||||||
getUserSessions,
|
|
||||||
checkInstanceHealth,
|
|
||||||
type Session as SessionApi,
|
|
||||||
type InstanceHealth,
|
|
||||||
} from "../api/sessions";
|
|
||||||
import { ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { SessionList } from "../components/features/session/session-list";
|
import { SessionList } from "../components/features/session/session-list";
|
||||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||||
|
import { useSessions } from "../state/sessions";
|
||||||
|
|
||||||
type HomeStatus = "loading" | "ready" | "error";
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -20,13 +16,11 @@ const summaryCards = [
|
|||||||
{ label: "Repositories", key: "repositories" },
|
{ label: "Repositories", key: "repositories" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type SessionView = SessionApi;
|
|
||||||
|
|
||||||
export const HomePage = () => {
|
export const HomePage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { sessions, refreshSessions } = useSessions();
|
||||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<
|
const [tunnelHealth, setTunnelHealth] = useState<
|
||||||
Record<string, InstanceHealth>
|
Record<string, InstanceHealth>
|
||||||
>({});
|
>({});
|
||||||
@@ -35,17 +29,16 @@ export const HomePage = () => {
|
|||||||
const loadHome = useCallback(async () => {
|
const loadHome = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
const [dashboard, sessionData] = await Promise.all([
|
const [dashboard] = await Promise.all([
|
||||||
getDashboardSummary(),
|
getDashboardSummary(),
|
||||||
getUserSessions(),
|
refreshSessions(),
|
||||||
]);
|
]);
|
||||||
setSummary(dashboard);
|
setSummary(dashboard);
|
||||||
setSessions(sessionData as SessionView[]);
|
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [refreshSessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadHome();
|
void loadHome();
|
||||||
@@ -58,7 +51,7 @@ export const HomePage = () => {
|
|||||||
handleStop,
|
handleStop,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleRecreateTunnel,
|
handleRecreateTunnel,
|
||||||
} = useInstanceActions({ onRefresh: loadHome });
|
} = useInstanceActions({ onRefresh: refreshSessions });
|
||||||
|
|
||||||
// Poll tunnel health every 30 seconds for running instances
|
// Poll tunnel health every 30 seconds for running instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
|
|||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ProjectsPage } from "./projects";
|
import { ProjectsPage } from "./ProjectsPage";
|
||||||
import * as projectsApi from "../api/projects";
|
import * as projectsApi from "../api/projects";
|
||||||
|
|
||||||
const mockProjects = [
|
const mockProjects = [
|
||||||
|
|||||||
+211
-350
@@ -1,144 +1,198 @@
|
|||||||
/** Projects page with inline repositories and workspaces. */
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import {
|
|
||||||
createProject,
|
|
||||||
deleteProject,
|
|
||||||
listProjects,
|
|
||||||
updateProject,
|
|
||||||
type ProjectCreateInput,
|
|
||||||
type ProjectUpdateInput,
|
|
||||||
} from "../api/projects";
|
|
||||||
import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
|
|
||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
ErrorState,
|
ErrorState,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
} from "../components/data-states";
|
} from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { ProjectCard } from "../components/features/project/ProjectCard";
|
||||||
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
import { ProjectDialog } from "../components/features/project/ProjectDialog";
|
||||||
|
import { RepositoryCreateDialog } from "../components/features/project/repository-create-dialog";
|
||||||
|
import { MobileListView } from "../components/features/mobile/mobile-list-view";
|
||||||
|
import { MobileFAB } from "../components/features/mobile/mobile-fab";
|
||||||
|
import { useProjects } from "../hooks/use-projects";
|
||||||
|
import type { ProjectWithRepos } from "../types";
|
||||||
|
|
||||||
type DialogMode = "none" | "create" | "edit";
|
type MobileView = "list" | "detail" | "create-project" | "create-repo";
|
||||||
|
|
||||||
export const ProjectsPage = () => {
|
export const ProjectsPage = () => {
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||||
|
const [selectedProject, setSelectedProject] =
|
||||||
|
useState<ProjectWithRepos | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: projects,
|
projects,
|
||||||
status,
|
status,
|
||||||
reload,
|
reload,
|
||||||
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
|
dialogMode,
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
formName,
|
||||||
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
|
setFormName,
|
||||||
null,
|
formDescription,
|
||||||
);
|
setFormDescription,
|
||||||
const [formName, setFormName] = useState("");
|
formError,
|
||||||
const [formDescription, setFormDescription] = useState("");
|
deleteConfirmId,
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
setDeleteConfirmId,
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
expandedProject,
|
||||||
const [expandedProject, setExpandedProject] = useState<string | null>(null);
|
setExpandedProject,
|
||||||
const [creatingWorkspace, setCreatingWorkspace] = useState<{
|
creatingWorkspace,
|
||||||
projectId: string;
|
setCreatingWorkspace,
|
||||||
repoId: string;
|
workspaceLoading,
|
||||||
} | null>(null);
|
openCreate,
|
||||||
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
|
openEdit,
|
||||||
|
closeDialog,
|
||||||
|
handleSubmit,
|
||||||
|
handleDelete,
|
||||||
|
handleSyncWorkspace,
|
||||||
|
handleDeleteWorkspace,
|
||||||
|
} = useProjects();
|
||||||
|
|
||||||
const safeProjects = projects ?? [];
|
const isEmpty = status === "ready" && projects.length === 0;
|
||||||
|
|
||||||
const openCreate = () => {
|
/* ── Mobile views ── */
|
||||||
setFormName("");
|
if (isMobile) {
|
||||||
setFormDescription("");
|
if (mobileView === "create-project") {
|
||||||
setFormError(null);
|
return (
|
||||||
setEditingProject(null);
|
<div className="mobile-page">
|
||||||
setDialogMode("create");
|
<ProjectDialog
|
||||||
};
|
mode="create"
|
||||||
|
name={formName}
|
||||||
const openEdit = (project: ProjectWithRepos) => {
|
description={formDescription}
|
||||||
setFormName(project.name);
|
error={formError}
|
||||||
setFormDescription(project.description ?? "");
|
onNameChange={setFormName}
|
||||||
setFormError(null);
|
onDescriptionChange={setFormDescription}
|
||||||
setEditingProject(project);
|
onSubmit={handleSubmit}
|
||||||
setDialogMode("edit");
|
onCancel={() => {
|
||||||
};
|
closeDialog();
|
||||||
|
setMobileView("list");
|
||||||
const closeDialog = () => {
|
}}
|
||||||
setDialogMode("none");
|
/>
|
||||||
setEditingProject(null);
|
</div>
|
||||||
setFormError(null);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
if (!formName.trim()) {
|
|
||||||
setFormError("Project name is required");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (mobileView === "create-repo" && selectedProject) {
|
||||||
if (dialogMode === "create") {
|
return (
|
||||||
const input: ProjectCreateInput = {
|
<div className="mobile-page">
|
||||||
name: formName.trim(),
|
<RepositoryCreateDialog
|
||||||
description: formDescription.trim() || null,
|
projectId={selectedProject.id}
|
||||||
};
|
open={true}
|
||||||
await createProject(input);
|
title="Add Repository"
|
||||||
} else if (dialogMode === "edit" && editingProject) {
|
onClose={() => setMobileView("detail")}
|
||||||
const input: ProjectUpdateInput = {
|
onCreated={async () => {
|
||||||
name: formName.trim(),
|
setMobileView("detail");
|
||||||
description: formDescription.trim() || null,
|
await reload();
|
||||||
};
|
}}
|
||||||
await updateProject(editingProject.id, input);
|
/>
|
||||||
}
|
</div>
|
||||||
closeDialog();
|
);
|
||||||
reload();
|
|
||||||
} catch {
|
|
||||||
setFormError("Failed to save project");
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (projectId: string) => {
|
if (mobileView === "detail" && selectedProject) {
|
||||||
try {
|
return (
|
||||||
await deleteProject(projectId);
|
<div className="mobile-page">
|
||||||
setDeleteConfirmId(null);
|
<ProjectCard
|
||||||
reload();
|
project={selectedProject}
|
||||||
} catch {
|
expanded={true}
|
||||||
setDeleteConfirmId(null);
|
showBackButton={true}
|
||||||
|
deleteConfirm={deleteConfirmId === selectedProject.id}
|
||||||
|
workspaceLoading={workspaceLoading}
|
||||||
|
showCreateForm={
|
||||||
|
creatingWorkspace?.projectId === selectedProject.id
|
||||||
|
? creatingWorkspace.repoId
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onBack={() => {
|
||||||
|
setMobileView("list");
|
||||||
|
setSelectedProject(null);
|
||||||
|
}}
|
||||||
|
onEdit={() => {
|
||||||
|
setFormName(selectedProject.name);
|
||||||
|
setFormDescription(selectedProject.description ?? "");
|
||||||
|
setMobileView("create-project");
|
||||||
|
}}
|
||||||
|
onDelete={() => setDeleteConfirmId(selectedProject.id)}
|
||||||
|
onConfirmDelete={() => {
|
||||||
|
void handleDelete(selectedProject.id);
|
||||||
|
setMobileView("list");
|
||||||
|
setSelectedProject(null);
|
||||||
|
}}
|
||||||
|
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||||
|
onCreateWorkspace={(repoId) =>
|
||||||
|
setCreatingWorkspace({
|
||||||
|
projectId: selectedProject.id,
|
||||||
|
repoId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onWorkspaceAction={(repoId, workspace, action) => {
|
||||||
|
if (action === "sync") {
|
||||||
|
void handleSyncWorkspace(selectedProject.id, repoId, workspace);
|
||||||
|
} else if (action === "delete") {
|
||||||
|
void handleDeleteWorkspace(workspace);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onAddRepository={() => setMobileView("create-repo")}
|
||||||
|
onCancelCreate={() => setCreatingWorkspace(null)}
|
||||||
|
onCreated={() => {
|
||||||
|
setCreatingWorkspace(null);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleSyncWorkspace = async (
|
/* Mobile list view */
|
||||||
projectId: string,
|
return (
|
||||||
repoId: string,
|
<div className="mobile-page">
|
||||||
workspace: WorkspaceSummary,
|
<div className="mobile-page-header">
|
||||||
) => {
|
<h1>Projects</h1>
|
||||||
setWorkspaceLoading(workspace.id);
|
<span className="muted">
|
||||||
try {
|
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||||
await syncWorkspace(projectId, repoId, workspace.id);
|
</span>
|
||||||
reload();
|
</div>
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : "Failed to sync workspace");
|
|
||||||
} finally {
|
|
||||||
setWorkspaceLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteWorkspace = async (workspace: WorkspaceSummary) => {
|
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||||
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;
|
{status === "error" && (
|
||||||
|
<ErrorState message="Failed to load projects" onRetry={reload} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEmpty && (
|
||||||
|
<EmptyState message="No projects yet. Create your first project above." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "ready" && projects.length > 0 && (
|
||||||
|
<MobileListView
|
||||||
|
items={projects.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.name,
|
||||||
|
subtitle: `${p.repositories?.length ?? 0} repo${(p.repositories?.length ?? 0) !== 1 ? "s" : ""}${p.description ? " · " + p.description : ""}`,
|
||||||
|
}))}
|
||||||
|
onItemClick={(id) => {
|
||||||
|
const project = projects.find((p) => p.id === id);
|
||||||
|
if (project) {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setMobileView("detail");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
emptyMessage="No projects yet"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MobileFAB
|
||||||
|
onClick={() => {
|
||||||
|
openCreate();
|
||||||
|
setMobileView("create-project");
|
||||||
|
}}
|
||||||
|
label="Create project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop view ── */
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -159,13 +213,20 @@ export const ProjectsPage = () => {
|
|||||||
<EmptyState message="No projects yet. Create your first project above." />
|
<EmptyState message="No projects yet. Create your first project above." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "ready" && safeProjects.length > 0 && (
|
{status === "ready" && projects.length > 0 && (
|
||||||
<div className="project-list">
|
<div className="project-list">
|
||||||
{safeProjects.map((project) => (
|
{projects.map((project) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
project={project}
|
project={project}
|
||||||
expanded={expandedProject === project.id}
|
expanded={expandedProject === project.id}
|
||||||
|
deleteConfirm={deleteConfirmId === project.id}
|
||||||
|
workspaceLoading={workspaceLoading}
|
||||||
|
showCreateForm={
|
||||||
|
creatingWorkspace?.projectId === project.id
|
||||||
|
? creatingWorkspace.repoId
|
||||||
|
: null
|
||||||
|
}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
setExpandedProject(
|
setExpandedProject(
|
||||||
expandedProject === project.id ? null : project.id,
|
expandedProject === project.id ? null : project.id,
|
||||||
@@ -173,7 +234,6 @@ export const ProjectsPage = () => {
|
|||||||
}
|
}
|
||||||
onEdit={() => openEdit(project)}
|
onEdit={() => openEdit(project)}
|
||||||
onDelete={() => setDeleteConfirmId(project.id)}
|
onDelete={() => setDeleteConfirmId(project.id)}
|
||||||
deleteConfirm={deleteConfirmId === project.id}
|
|
||||||
onConfirmDelete={() => void handleDelete(project.id)}
|
onConfirmDelete={() => void handleDelete(project.id)}
|
||||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||||
onCreateWorkspace={(repoId) =>
|
onCreateWorkspace={(repoId) =>
|
||||||
@@ -186,13 +246,11 @@ export const ProjectsPage = () => {
|
|||||||
void handleDeleteWorkspace(workspace);
|
void handleDeleteWorkspace(workspace);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
workspaceLoading={workspaceLoading}
|
|
||||||
showCreateForm={
|
|
||||||
creatingWorkspace?.projectId === project.id
|
|
||||||
? creatingWorkspace.repoId
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onCancelCreate={() => setCreatingWorkspace(null)}
|
onCancelCreate={() => setCreatingWorkspace(null)}
|
||||||
|
onAddRepository={() => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setMobileView("create-repo");
|
||||||
|
}}
|
||||||
onCreated={() => {
|
onCreated={() => {
|
||||||
setCreatingWorkspace(null);
|
setCreatingWorkspace(null);
|
||||||
reload();
|
reload();
|
||||||
@@ -203,231 +261,34 @@ export const ProjectsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{dialogMode !== "none" && (
|
{dialogMode !== "none" && (
|
||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<ProjectDialog
|
||||||
<div className="dialog">
|
mode={dialogMode}
|
||||||
<h2>
|
name={formName}
|
||||||
{dialogMode === "create" ? "Create Project" : "Edit Project"}
|
description={formDescription}
|
||||||
</h2>
|
error={formError}
|
||||||
<form onSubmit={handleSubmit} className="stack">
|
onNameChange={setFormName}
|
||||||
<label className="form-field">
|
onDescriptionChange={setFormDescription}
|
||||||
Name
|
onSubmit={handleSubmit}
|
||||||
<input
|
onCancel={closeDialog}
|
||||||
type="text"
|
/>
|
||||||
value={formName}
|
)}
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
|
||||||
placeholder="Project name"
|
{mobileView === "create-repo" && selectedProject && (
|
||||||
/>
|
<RepositoryCreateDialog
|
||||||
</label>
|
projectId={selectedProject.id}
|
||||||
<label className="form-field">
|
open={true}
|
||||||
Description
|
title="Add Repository"
|
||||||
<textarea
|
onClose={() => {
|
||||||
value={formDescription}
|
setMobileView("list");
|
||||||
onChange={(e) => setFormDescription(e.target.value)}
|
setSelectedProject(null);
|
||||||
placeholder="Optional description"
|
}}
|
||||||
rows={3}
|
onCreated={async () => {
|
||||||
/>
|
setMobileView("list");
|
||||||
</label>
|
setSelectedProject(null);
|
||||||
{formError && <p className="error-text">{formError}</p>}
|
await reload();
|
||||||
<div className="dialog-actions">
|
}}
|
||||||
<button
|
/>
|
||||||
className="secondary-button"
|
|
||||||
onClick={closeDialog}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="primary-button" type="submit">
|
|
||||||
{dialogMode === "create" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Create
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="save" size="sm" />
|
|
||||||
Save
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ─── Project Card ─── */
|
|
||||||
|
|
||||||
function ProjectCard({
|
|
||||||
project,
|
|
||||||
expanded,
|
|
||||||
onToggle,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
deleteConfirm,
|
|
||||||
onConfirmDelete,
|
|
||||||
onCancelDelete,
|
|
||||||
onCreateWorkspace,
|
|
||||||
onWorkspaceAction,
|
|
||||||
workspaceLoading,
|
|
||||||
showCreateForm,
|
|
||||||
onCancelCreate,
|
|
||||||
onCreated,
|
|
||||||
}: {
|
|
||||||
project: ProjectWithRepos;
|
|
||||||
expanded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
deleteConfirm: boolean;
|
|
||||||
onConfirmDelete: () => void;
|
|
||||||
onCancelDelete: () => void;
|
|
||||||
onCreateWorkspace: (repoId: string) => void;
|
|
||||||
onWorkspaceAction: (
|
|
||||||
repoId: string,
|
|
||||||
workspace: WorkspaceSummary,
|
|
||||||
action: "sync" | "delete",
|
|
||||||
) => void;
|
|
||||||
workspaceLoading: string | null;
|
|
||||||
onCancelCreate: () => void;
|
|
||||||
showCreateForm: string | null;
|
|
||||||
onCreated: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<article className="card project-card">
|
|
||||||
<div className="project-info-row">
|
|
||||||
<button
|
|
||||||
className="project-toggle"
|
|
||||||
onClick={onToggle}
|
|
||||||
type="button"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
>
|
|
||||||
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
|
|
||||||
<h3>{project.name}</h3>
|
|
||||||
{project.repositories.length > 0 && (
|
|
||||||
<span className="repo-count">
|
|
||||||
{project.repositories.length} repo
|
|
||||||
{project.repositories.length > 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div className="project-actions">
|
|
||||||
<button className="ghost-button" onClick={onEdit} type="button">
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
{deleteConfirm ? (
|
|
||||||
<div className="delete-confirm">
|
|
||||||
<span>Are you sure?</span>
|
|
||||||
<button
|
|
||||||
className="danger-button"
|
|
||||||
onClick={onConfirmDelete}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={onCancelDelete}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button danger-text"
|
|
||||||
onClick={onDelete}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="project-detail">
|
|
||||||
{project.repositories.length === 0 ? (
|
|
||||||
<p className="muted">No repositories yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="repo-list">
|
|
||||||
{project.repositories.map((repo) => (
|
|
||||||
<div key={repo.id} className="repo-block">
|
|
||||||
<div className="repo-header">
|
|
||||||
<h4>{repo.name}</h4>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-primary"
|
|
||||||
onClick={() => onCreateWorkspace(repo.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="add" size="sm" /> New Workspace
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showCreateForm === repo.id && (
|
|
||||||
<WorkspaceCreateForm
|
|
||||||
defaultProjectId={project.id}
|
|
||||||
defaultRepoId={repo.id}
|
|
||||||
onSubmit={onCreated}
|
|
||||||
onCancel={onCancelCreate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{repo.workspaces.length === 0 ? (
|
|
||||||
<p className="muted">No workspaces.</p>
|
|
||||||
) : (
|
|
||||||
<div className="workspace-grid">
|
|
||||||
{repo.workspaces.map((ws) => (
|
|
||||||
<div
|
|
||||||
key={ws.id}
|
|
||||||
className={`workspace-chip ${ws.status}`}
|
|
||||||
>
|
|
||||||
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
|
|
||||||
<span className="ws-branch">
|
|
||||||
<Icon name="branch" size="sm" /> {ws.branch}
|
|
||||||
</span>
|
|
||||||
{ws.instance_count > 0 && (
|
|
||||||
<span className="ws-instances">
|
|
||||||
{ws.instance_count} tool
|
|
||||||
{ws.instance_count > 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="ws-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={workspaceLoading === ws.id}
|
|
||||||
onClick={() =>
|
|
||||||
onWorkspaceAction(repo.id, ws, "sync")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="danger-text"
|
|
||||||
disabled={workspaceLoading === ws.id}
|
|
||||||
onClick={() =>
|
|
||||||
onWorkspaceAction(repo.id, ws, "delete")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,505 +1,89 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
import { useRepoWorkspace } from "../hooks/use-repo-workspace";
|
||||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
|
||||||
|
|
||||||
import { apiClient } from "../api/client";
|
|
||||||
import {
|
|
||||||
getRepositoryStatus,
|
|
||||||
listRepositories,
|
|
||||||
type GitRepository,
|
|
||||||
type GitStatus,
|
|
||||||
} from "../api/git-repositories";
|
|
||||||
import { CommitPanel } from "../components/features/git/commit-panel";
|
|
||||||
import { FileEditor } from "../components/features/git/file-editor";
|
|
||||||
import { GitToolbar } from "../components/features/git/git-toolbar";
|
|
||||||
import { InstanceList } from "../components/features/tool/instance-list";
|
|
||||||
import { WorkspaceHeader } from "../components/features/workspace/workspace-header";
|
import { WorkspaceHeader } from "../components/features/workspace/workspace-header";
|
||||||
import { listToolTypes } from "../api/tool-types";
|
import { WorkspaceLayout } from "../components/features/workspace/WorkspaceLayout";
|
||||||
import type { ToolType } from "../api/tool-types";
|
|
||||||
|
|
||||||
type MobileTab = "files" | "editor" | "git" | "terminal";
|
type MobileTab = "files" | "editor" | "git" | "terminal";
|
||||||
|
|
||||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
|
||||||
|
|
||||||
interface FileTreeEntry {
|
|
||||||
name: string;
|
|
||||||
type: "file" | "directory";
|
|
||||||
path: string;
|
|
||||||
size?: number;
|
|
||||||
mode?: string;
|
|
||||||
last_commit?: {
|
|
||||||
hash: string;
|
|
||||||
message: string;
|
|
||||||
author: string;
|
|
||||||
date: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RepoWorkspace = () => {
|
export const RepoWorkspace = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
projectId,
|
||||||
const isMobile = useMobileViewport();
|
project,
|
||||||
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
status,
|
||||||
|
repositories,
|
||||||
|
selectedRepoId,
|
||||||
|
selectedRepo,
|
||||||
|
branches,
|
||||||
|
currentBranch,
|
||||||
|
gitStatus,
|
||||||
|
toolTypes,
|
||||||
|
handleRepoChange,
|
||||||
|
handleBranchChange,
|
||||||
|
loadGitStatus,
|
||||||
|
loadBranches,
|
||||||
|
loadRepositories,
|
||||||
|
} = useRepoWorkspace();
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
||||||
|
|
||||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
return (
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
<section className="repo-workspace">
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
{project && (
|
||||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
|
<WorkspaceHeader
|
||||||
searchParams.get("repo")
|
project={project}
|
||||||
);
|
currentRepo={selectedRepo || null}
|
||||||
const [branches, setBranches] = useState<string[]>([]);
|
/>
|
||||||
const [currentBranch, setCurrentBranch] = useState<string>("main");
|
)}
|
||||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
|
||||||
|
|
||||||
const loadProject = useCallback(async () => {
|
{status === "loading" && (
|
||||||
if (!projectId) return;
|
<LoadingState message="Loading repositories..." />
|
||||||
try {
|
)}
|
||||||
const response = await apiClient.get(`/projects/${projectId}`);
|
|
||||||
setProject(response.data);
|
|
||||||
} catch {
|
|
||||||
setProject(null);
|
|
||||||
}
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const loadRepositories = useCallback(async () => {
|
{status === "error" && (
|
||||||
if (!projectId) return;
|
<ErrorState
|
||||||
|
message="Failed to load repositories"
|
||||||
|
onRetry={() => void loadRepositories()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
setStatus("loading");
|
{status === "empty" && (
|
||||||
try {
|
<div className="card stack">
|
||||||
const data = await listRepositories(projectId);
|
<EmptyState message="No repositories in this project yet." />
|
||||||
setRepositories(data);
|
<Link
|
||||||
|
className="primary-button"
|
||||||
|
to={`/projects/${projectId}/settings/repositories`}
|
||||||
|
>
|
||||||
|
Manage Repositories
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
if (data.length === 0) {
|
{status === "ready" && repositories.length > 0 && (
|
||||||
setStatus("empty");
|
<WorkspaceLayout
|
||||||
} else {
|
projectId={projectId!}
|
||||||
setStatus("ready");
|
project={project}
|
||||||
// If no repo selected, select the first one
|
isMobile={isMobile}
|
||||||
if (!selectedRepoId) {
|
mobileTab={mobileTab}
|
||||||
setSelectedRepoId(data[0].id);
|
selectedRepoId={selectedRepoId}
|
||||||
const newParams = new URLSearchParams(searchParams);
|
selectedRepo={selectedRepo}
|
||||||
newParams.set("repo", data[0].id);
|
branches={branches}
|
||||||
setSearchParams(newParams, { replace: true });
|
currentBranch={currentBranch}
|
||||||
}
|
gitStatus={gitStatus}
|
||||||
}
|
toolTypes={toolTypes}
|
||||||
} catch {
|
repositories={repositories}
|
||||||
setRepositories([]);
|
onMobileTabChange={setMobileTab}
|
||||||
setStatus("error");
|
onRepoChange={handleRepoChange}
|
||||||
}
|
onBranchChange={handleBranchChange}
|
||||||
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
|
onRefresh={() => {
|
||||||
|
void loadBranches();
|
||||||
const loadBranches = useCallback(async () => {
|
void loadGitStatus();
|
||||||
if (!projectId || !selectedRepoId) return;
|
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||||
try {
|
}}
|
||||||
const response = await apiClient.get(
|
/>
|
||||||
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
|
)}
|
||||||
);
|
</section>
|
||||||
const branchList = response.data.branches.map((b: { name: string }) => b.name);
|
);
|
||||||
setBranches(branchList);
|
|
||||||
const defaultBranch = response.data.default_branch;
|
|
||||||
if (defaultBranch) {
|
|
||||||
setCurrentBranch(defaultBranch);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setBranches([]);
|
|
||||||
}
|
|
||||||
}, [projectId, selectedRepoId]);
|
|
||||||
|
|
||||||
const loadGitStatus = useCallback(async () => {
|
|
||||||
if (!projectId || !selectedRepoId) return;
|
|
||||||
try {
|
|
||||||
const data = await getRepositoryStatus(projectId, selectedRepoId);
|
|
||||||
setGitStatus(data);
|
|
||||||
} catch {
|
|
||||||
setGitStatus(null);
|
|
||||||
}
|
|
||||||
}, [projectId, selectedRepoId]);
|
|
||||||
|
|
||||||
const loadToolTypes = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await listToolTypes();
|
|
||||||
setToolTypes(data);
|
|
||||||
} catch {
|
|
||||||
setToolTypes([]);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadProject();
|
|
||||||
void loadRepositories();
|
|
||||||
void loadToolTypes();
|
|
||||||
}, [loadProject, loadRepositories, loadToolTypes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadBranches();
|
|
||||||
void loadGitStatus();
|
|
||||||
}, [loadBranches, loadGitStatus]);
|
|
||||||
|
|
||||||
const handleRepoChange = (repoId: string) => {
|
|
||||||
setSelectedRepoId(repoId);
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
newParams.set("repo", repoId);
|
|
||||||
newParams.delete("branch");
|
|
||||||
newParams.delete("path");
|
|
||||||
setSearchParams(newParams);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="repo-workspace">
|
|
||||||
{project && (
|
|
||||||
<WorkspaceHeader
|
|
||||||
project={project}
|
|
||||||
currentRepo={selectedRepo || null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "loading" && (
|
|
||||||
<LoadingState message="Loading repositories..." />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "error" && (
|
|
||||||
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "empty" && (
|
|
||||||
<div className="card stack">
|
|
||||||
<EmptyState message="No repositories in this project yet." />
|
|
||||||
<Link
|
|
||||||
className="primary-button"
|
|
||||||
to={`/projects/${projectId}/settings/repositories`}
|
|
||||||
>
|
|
||||||
Manage Repositories
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "ready" && repositories.length > 0 && (
|
|
||||||
<>
|
|
||||||
{isMobile ? (
|
|
||||||
// Mobile Layout
|
|
||||||
<div className="mobile-workspace">
|
|
||||||
<div className="mobile-workspace-header">
|
|
||||||
<select
|
|
||||||
value={selectedRepoId || ""}
|
|
||||||
onChange={(e) => handleRepoChange(e.target.value)}
|
|
||||||
className="mobile-repo-selector"
|
|
||||||
>
|
|
||||||
{repositories.map((repo) => (
|
|
||||||
<option key={repo.id} value={repo.id}>
|
|
||||||
{repo.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{selectedRepoId && (
|
|
||||||
<select
|
|
||||||
value={currentBranch}
|
|
||||||
onChange={(e) => {
|
|
||||||
const branch = e.target.value;
|
|
||||||
setCurrentBranch(branch);
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
newParams.set("branch", branch);
|
|
||||||
setSearchParams(newParams);
|
|
||||||
}}
|
|
||||||
className="mobile-branch-selector"
|
|
||||||
>
|
|
||||||
{branches.map((branch) => (
|
|
||||||
<option key={branch} value={branch}>
|
|
||||||
{branch}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mobile-workspace-content">
|
|
||||||
{mobileTab === "files" && selectedRepoId && (
|
|
||||||
<FileBrowser
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
gitStatus={gitStatus}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{mobileTab === "editor" && selectedRepoId && (
|
|
||||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
|
||||||
)}
|
|
||||||
{mobileTab === "git" && selectedRepoId && gitStatus && (
|
|
||||||
<div className="mobile-git-view">
|
|
||||||
<CommitPanel
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
modified={gitStatus.modified}
|
|
||||||
added={gitStatus.added}
|
|
||||||
deleted={gitStatus.deleted}
|
|
||||||
untracked={gitStatus.untracked}
|
|
||||||
onCommit={() => {
|
|
||||||
void loadGitStatus();
|
|
||||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mobileTab === "terminal" && selectedRepoId && (
|
|
||||||
<InstanceList
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
projectName={project?.name}
|
|
||||||
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
|
||||||
toolTypes={toolTypes}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mobile-workspace-tabs">
|
|
||||||
<button
|
|
||||||
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
|
|
||||||
onClick={() => setMobileTab("files")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="folder" size="sm" />
|
|
||||||
<span>Files</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
|
|
||||||
onClick={() => setMobileTab("editor")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
<span>Editor</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
|
|
||||||
onClick={() => setMobileTab("git")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="branch" size="sm" />
|
|
||||||
<span>Git</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
|
|
||||||
onClick={() => setMobileTab("terminal")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="terminal" size="sm" />
|
|
||||||
<span>Terminal</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Desktop Layout
|
|
||||||
<>
|
|
||||||
{selectedRepoId && (
|
|
||||||
<GitToolbar
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
currentBranch={currentBranch}
|
|
||||||
branches={branches}
|
|
||||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
|
||||||
isMirror={Boolean(selectedRepo?.is_mirror)}
|
|
||||||
onBranchChange={(branch) => {
|
|
||||||
setCurrentBranch(branch);
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
newParams.set("branch", branch);
|
|
||||||
setSearchParams(newParams);
|
|
||||||
}}
|
|
||||||
onRefresh={() => {
|
|
||||||
void loadBranches();
|
|
||||||
void loadGitStatus();
|
|
||||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="workspace-layout">
|
|
||||||
<aside className="workspace-sidebar">
|
|
||||||
<div className="sidebar-section">
|
|
||||||
<label className="form-field">
|
|
||||||
Repository
|
|
||||||
<select
|
|
||||||
value={selectedRepoId || ""}
|
|
||||||
onChange={(e) => handleRepoChange(e.target.value)}
|
|
||||||
>
|
|
||||||
{repositories.map((repo) => (
|
|
||||||
<option key={repo.id} value={repo.id}>
|
|
||||||
{repo.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedRepoId && (
|
|
||||||
<>
|
|
||||||
<FileBrowser
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
gitStatus={gitStatus}
|
|
||||||
/>
|
|
||||||
{gitStatus && (
|
|
||||||
<CommitPanel
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
modified={gitStatus.modified}
|
|
||||||
added={gitStatus.added}
|
|
||||||
deleted={gitStatus.deleted}
|
|
||||||
untracked={gitStatus.untracked}
|
|
||||||
onCommit={() => {
|
|
||||||
void loadGitStatus();
|
|
||||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<InstanceList
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
projectName={project?.name}
|
|
||||||
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
|
||||||
toolTypes={toolTypes}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="workspace-main">
|
|
||||||
{selectedRepoId && (
|
|
||||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// File Browser Component
|
|
||||||
const FileBrowser = ({
|
|
||||||
projectId,
|
|
||||||
repoId,
|
|
||||||
gitStatus,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
repoId: string;
|
|
||||||
gitStatus: GitStatus | null;
|
|
||||||
}) => {
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const branch = searchParams.get("branch") || "main";
|
|
||||||
const path = searchParams.get("path") || "";
|
|
||||||
|
|
||||||
const loadFiles = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/files`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
branch,
|
|
||||||
path,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setEntries(response.data.entries || []);
|
|
||||||
} catch {
|
|
||||||
setError("Failed to load files");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [projectId, repoId, branch, path]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadFiles();
|
|
||||||
}, [loadFiles]);
|
|
||||||
|
|
||||||
// Listen for refresh events
|
|
||||||
useEffect(() => {
|
|
||||||
const handleRefresh = () => void loadFiles();
|
|
||||||
window.addEventListener("refresh-file-tree", handleRefresh);
|
|
||||||
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
|
||||||
}, [loadFiles]);
|
|
||||||
|
|
||||||
const handleEntryClick = (entry: FileTreeEntry) => {
|
|
||||||
if (entry.type === "directory") {
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
newParams.set("path", entry.path);
|
|
||||||
setSearchParams(newParams);
|
|
||||||
} else {
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
newParams.set("file", entry.path);
|
|
||||||
setSearchParams(newParams);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigateUp = () => {
|
|
||||||
if (!path) return;
|
|
||||||
const parentPath = path.split("/").slice(0, -1).join("/");
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
if (parentPath) {
|
|
||||||
newParams.set("path", parentPath);
|
|
||||||
} else {
|
|
||||||
newParams.delete("path");
|
|
||||||
}
|
|
||||||
setSearchParams(newParams);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFileStatus = (filePath: string): string | null => {
|
|
||||||
if (!gitStatus) return null;
|
|
||||||
if (gitStatus.modified.includes(filePath)) return "modified";
|
|
||||||
if (gitStatus.added.includes(filePath)) return "added";
|
|
||||||
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
|
||||||
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <p className="muted">Loading files...</p>;
|
|
||||||
if (error) return <p className="error-text">{error}</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="file-tree">
|
|
||||||
{path && (
|
|
||||||
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
|
|
||||||
<Icon name="folder" size="sm" /> ..
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{entries.length === 0 && (
|
|
||||||
<EmptyState message="No files in this repository yet." />
|
|
||||||
)}
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={entry.path}
|
|
||||||
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
|
||||||
onClick={() => handleEntryClick(entry)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
|
|
||||||
{fileStatus && (
|
|
||||||
<span className={`file-status-indicator ${fileStatus}`}>
|
|
||||||
{fileStatus === "modified" && "M"}
|
|
||||||
{fileStatus === "added" && "A"}
|
|
||||||
{fileStatus === "deleted" && "D"}
|
|
||||||
{fileStatus === "untracked" && "?"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,46 +1,39 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import {
|
import { checkInstanceHealth, type InstanceHealth } from "../api/sessions";
|
||||||
getUserSessions,
|
|
||||||
type Session,
|
|
||||||
checkInstanceHealth,
|
|
||||||
} from "../api/sessions";
|
|
||||||
import { getUserConfig } from "../api/settings";
|
import { getUserConfig } from "../api/settings";
|
||||||
import { ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { SessionList } from "../components/features/session/session-list";
|
import { SessionList } from "../components/features/session/session-list";
|
||||||
import { SessionCard } from "../components/features/session/session-card";
|
import { SessionCard } from "../components/features/session/session-card";
|
||||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||||
import type { InstanceHealth } from "../api/sessions";
|
import { useSessions } from "../state/sessions";
|
||||||
|
|
||||||
type SessionsStatus = "loading" | "ready" | "error";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
export const SessionsPage = () => {
|
export const SessionsPage = () => {
|
||||||
|
const { sessions, isLoading, error, refreshSessions } = useSessions();
|
||||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
|
||||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<
|
const [tunnelHealth, setTunnelHealth] = useState<
|
||||||
Record<string, InstanceHealth>
|
Record<string, InstanceHealth>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadPageData = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
const [sessionsData, config] = await Promise.all([
|
await refreshSessions();
|
||||||
getUserSessions(),
|
const config = await getUserConfig();
|
||||||
getUserConfig(),
|
|
||||||
]);
|
|
||||||
setSessions(sessionsData);
|
|
||||||
setLastSessionId(config.last_session_id ?? null);
|
setLastSessionId(config.last_session_id ?? null);
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [refreshSessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSessions();
|
void loadPageData();
|
||||||
}, [loadSessions]);
|
}, [loadPageData]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loadingSessionId,
|
loadingSessionId,
|
||||||
@@ -52,8 +45,9 @@ export const SessionsPage = () => {
|
|||||||
handleDelete,
|
handleDelete,
|
||||||
handleForceDelete,
|
handleForceDelete,
|
||||||
handleRecreateTunnel,
|
handleRecreateTunnel,
|
||||||
|
handleRename,
|
||||||
clearDirtyDelete,
|
clearDirtyDelete,
|
||||||
} = useInstanceActions({ onRefresh: loadSessions });
|
} = useInstanceActions({ onRefresh: refreshSessions });
|
||||||
|
|
||||||
// Poll health every 30 seconds for active web-enabled instances
|
// Poll health every 30 seconds for active web-enabled instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,22 +93,26 @@ export const SessionsPage = () => {
|
|||||||
|
|
||||||
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
|
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
|
||||||
|
|
||||||
|
const isPageLoading = status === "loading" || (status === "ready" && isLoading && sessions.length === 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack sessions-page">
|
<section className="stack sessions-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Sessions</h1>
|
<h1>Sessions</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Failed to load sessions"
|
message={error?.message ?? "Failed to load sessions"}
|
||||||
onRetry={() => void loadSessions()}
|
onRetry={() => void loadPageData()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "ready" && (
|
{(status === "loading" || isPageLoading) && (
|
||||||
|
<LoadingState message="Loading sessions..." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "ready" && !isPageLoading && (
|
||||||
<>
|
<>
|
||||||
{/* Last Session */}
|
{/* Last Session */}
|
||||||
{lastSession && (
|
{lastSession && (
|
||||||
@@ -124,6 +122,7 @@ export const SessionsPage = () => {
|
|||||||
session={lastSession}
|
session={lastSession}
|
||||||
onOpen={handleOpen}
|
onOpen={handleOpen}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onRename={handleRename}
|
||||||
isBusy={loadingSessionId === lastSession.id}
|
isBusy={loadingSessionId === lastSession.id}
|
||||||
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||||
/>
|
/>
|
||||||
@@ -139,6 +138,7 @@ export const SessionsPage = () => {
|
|||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onRecreateTunnel={handleRecreateTunnel}
|
onRecreateTunnel={handleRecreateTunnel}
|
||||||
|
onRename={handleRename}
|
||||||
actionBusyId={loadingSessionId}
|
actionBusyId={loadingSessionId}
|
||||||
tunnelHealth={tunnelHealth}
|
tunnelHealth={tunnelHealth}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getUserConfig,
|
getUserConfig,
|
||||||
updateUserConfig,
|
updateUserConfig,
|
||||||
@@ -8,7 +7,6 @@ import {
|
|||||||
type UserConfigUpdate,
|
type UserConfigUpdate,
|
||||||
} from "../api/settings";
|
} from "../api/settings";
|
||||||
import { ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -16,29 +14,7 @@ const TABS = [
|
|||||||
{ label: "SSH Keys", path: "ssh-keys" },
|
{ label: "SSH Keys", path: "ssh-keys" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
export { GeneralSettingsTab } from "../components/features/settings/GeneralSettingsTab";
|
||||||
{ value: "system", label: "System" },
|
|
||||||
{ value: "light", label: "Light" },
|
|
||||||
{ value: "dark", label: "Dark" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TOAST_LEVEL_OPTIONS = [
|
|
||||||
{ value: "all", label: "All" },
|
|
||||||
{ value: "errors", label: "Errors only" },
|
|
||||||
{ value: "none", label: "None" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MUTE_CATEGORIES = ["instance", "system", "health", "security"];
|
|
||||||
|
|
||||||
type SettingsOutletContext = {
|
|
||||||
config: UserConfig;
|
|
||||||
handleChange: (
|
|
||||||
key: keyof UserConfigUpdate,
|
|
||||||
value: string | string[] | null,
|
|
||||||
) => void;
|
|
||||||
handleSave: () => Promise<void>;
|
|
||||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -60,7 +36,6 @@ export const SettingsPage = () => {
|
|||||||
"idle" | "saving" | "saved" | "error"
|
"idle" | "saving" | "saved" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
|
|
||||||
// Sync loaded config into local editable state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadedConfig) {
|
if (loadedConfig) {
|
||||||
setConfig({
|
setConfig({
|
||||||
@@ -160,125 +135,3 @@ export const SettingsPage = () => {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GeneralSettingsTab = () => {
|
|
||||||
const { config, handleChange, handleSave, saveStatus } =
|
|
||||||
useOutletContext<SettingsOutletContext>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="stack">
|
|
||||||
<h2>General</h2>
|
|
||||||
<label className="form-field">
|
|
||||||
Theme
|
|
||||||
<select
|
|
||||||
value={config.theme}
|
|
||||||
onChange={(e) => handleChange("theme", e.target.value)}
|
|
||||||
>
|
|
||||||
{THEME_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Git user name
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.git_user_name ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange("git_user_name", e.target.value || null)
|
|
||||||
}
|
|
||||||
placeholder="Your git commit name"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Git user email
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={config.git_user_email ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange("git_user_email", e.target.value || null)
|
|
||||||
}
|
|
||||||
placeholder="your.email@example.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Default editor
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.default_editor ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange("default_editor", e.target.value || null)
|
|
||||||
}
|
|
||||||
placeholder="e.g., vscode, vim, cursor"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<h3>Notifications</h3>
|
|
||||||
<label className="form-field">
|
|
||||||
Toast level
|
|
||||||
<select
|
|
||||||
value={config.notification_toast_level ?? "all"}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange("notification_toast_level", e.target.value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{TOAST_LEVEL_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<fieldset className="form-field">
|
|
||||||
<legend>Mute categories</legend>
|
|
||||||
<div className="stack-sm">
|
|
||||||
{MUTE_CATEGORIES.map((cat) => (
|
|
||||||
<label
|
|
||||||
key={cat}
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(config.notification_mute_categories ?? []).includes(
|
|
||||||
cat,
|
|
||||||
)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const current = config.notification_mute_categories ?? [];
|
|
||||||
const next = e.target.checked
|
|
||||||
? [...current, cat]
|
|
||||||
: current.filter((c) => c !== cat);
|
|
||||||
handleChange("notification_mute_categories", next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{cat}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<div className="settings-actions">
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
onClick={() => void handleSave()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{saveStatus === "saving" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" /> Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="save" size="sm" /> Save Settings
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{saveStatus === "saved" && (
|
|
||||||
<span className="success-text">Settings saved!</span>
|
|
||||||
)}
|
|
||||||
{saveStatus === "error" && (
|
|
||||||
<span className="error-text">Failed to save</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,92 +1,35 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh-keys";
|
import { LoadingState } from "../components/data-states";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { useSSHKeys } from "../hooks/use-ssh-keys";
|
||||||
import { Icon } from "../components/icon";
|
import { SSHKeyCreateForm } from "../components/features/ssh-keys/SSHKeyCreateForm";
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { SSHKeyList } from "../components/features/ssh-keys/SSHKeyList";
|
||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
const {
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
keys,
|
||||||
const [generating, setGenerating] = useState(false);
|
status,
|
||||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
loadKeys,
|
||||||
const [signatures, setSignatures] = useState<Record<string, string>>({});
|
newKeyName,
|
||||||
const [signing, setSigning] = useState<Record<string, boolean>>({});
|
setNewKeyName,
|
||||||
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
|
generating,
|
||||||
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
mutationError,
|
||||||
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
signPayloads,
|
||||||
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
signatures,
|
||||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
signing,
|
||||||
|
verifyPayloads,
|
||||||
const safeKeys = keys ?? [];
|
verifySignatures,
|
||||||
|
verifyResults,
|
||||||
async function handleGenerate(e: React.FormEvent) {
|
verifying,
|
||||||
e.preventDefault();
|
handleGenerate,
|
||||||
if (!newKeyName.trim()) return;
|
handleDelete,
|
||||||
|
copyToClipboard,
|
||||||
try {
|
handleSign,
|
||||||
setGenerating(true);
|
handleVerify,
|
||||||
await createSSHKey({ name: newKeyName.trim() });
|
setSignPayloads,
|
||||||
setNewKeyName("");
|
setVerifyPayloads,
|
||||||
await loadKeys();
|
setVerifySignatures,
|
||||||
} catch {
|
} = useSSHKeys();
|
||||||
setMutationError("Failed to generate SSH key");
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(keyId: string) {
|
|
||||||
if (!confirm("Are you sure you want to delete this SSH key?")) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteSSHKey(keyId);
|
|
||||||
await loadKeys();
|
|
||||||
} catch {
|
|
||||||
setMutationError("Failed to delete SSH key");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToClipboard(text: string) {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSign(keyId: string) {
|
|
||||||
const payload = signPayloads[keyId];
|
|
||||||
if (!payload?.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
|
||||||
const result = await signPayload(keyId, { payload: payload.trim() });
|
|
||||||
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
|
||||||
setMutationError(null);
|
|
||||||
} catch {
|
|
||||||
setMutationError("Failed to sign payload");
|
|
||||||
} finally {
|
|
||||||
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleVerify(keyId: string) {
|
|
||||||
const payload = verifyPayloads[keyId];
|
|
||||||
const signature = verifySignatures[keyId];
|
|
||||||
if (!payload?.trim() || !signature?.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setVerifying((prev) => ({ ...prev, [keyId]: true }));
|
|
||||||
const result = await verifySignature(keyId, {
|
|
||||||
payload: payload.trim(),
|
|
||||||
signature: signature.trim(),
|
|
||||||
});
|
|
||||||
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
|
||||||
setMutationError(null);
|
|
||||||
} catch {
|
|
||||||
setMutationError("Failed to verify signature");
|
|
||||||
} finally {
|
|
||||||
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
||||||
|
|
||||||
@@ -104,174 +47,38 @@ export const SSHKeysPage = () => {
|
|||||||
|
|
||||||
{mutationError && <div className="error">{mutationError}</div>}
|
{mutationError && <div className="error">{mutationError}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleGenerate} className="stack">
|
<SSHKeyCreateForm
|
||||||
<div className="form-group">
|
newKeyName={newKeyName}
|
||||||
<label htmlFor="key-name">Key Name</label>
|
setNewKeyName={setNewKeyName}
|
||||||
<input
|
generating={generating}
|
||||||
id="key-name"
|
onSubmit={handleGenerate}
|
||||||
type="text"
|
/>
|
||||||
value={newKeyName}
|
|
||||||
onChange={(e) => setNewKeyName(e.target.value)}
|
|
||||||
placeholder="e.g., GitHub Work"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="primary-button" disabled={generating}>
|
|
||||||
{generating ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Generate SSH Key
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
|
<SSHKeyList
|
||||||
|
keys={keys}
|
||||||
<div className="keys-list">
|
status={status}
|
||||||
{safeKeys.length === 0 ? (
|
signPayloads={signPayloads}
|
||||||
<EmptyState message="No SSH keys yet. Generate one above." />
|
signatures={signatures}
|
||||||
) : (
|
signing={signing}
|
||||||
safeKeys.map((key) => (
|
verifyPayloads={verifyPayloads}
|
||||||
<div key={key.id} className="key-card">
|
verifySignatures={verifySignatures}
|
||||||
<div className="key-header">
|
verifyResults={verifyResults}
|
||||||
<h3>{key.name}</h3>
|
verifying={verifying}
|
||||||
<button
|
onLoadKeys={loadKeys}
|
||||||
onClick={() => handleDelete(key.id)}
|
onDelete={handleDelete}
|
||||||
className="danger-button"
|
onCopy={copyToClipboard}
|
||||||
>
|
onSign={handleSign}
|
||||||
<Icon name="delete" size="sm" />
|
onVerify={handleVerify}
|
||||||
Delete
|
onSignPayloadChange={(id, value) =>
|
||||||
</button>
|
setSignPayloads((prev) => ({ ...prev, [id]: value }))
|
||||||
</div>
|
}
|
||||||
<div className="key-meta">
|
onVerifyPayloadChange={(id, value) =>
|
||||||
<span className="muted">
|
setVerifyPayloads((prev) => ({ ...prev, [id]: value }))
|
||||||
Created: {new Date(key.created_at).toLocaleDateString()}
|
}
|
||||||
</span>
|
onVerifySignatureChange={(id, value) =>
|
||||||
</div>
|
setVerifySignatures((prev) => ({ ...prev, [id]: value }))
|
||||||
<div className="key-public">
|
}
|
||||||
<code>{key.public_key.substring(0, 50)}...</code>
|
/>
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(key.public_key)}
|
|
||||||
className="secondary-button"
|
|
||||||
>
|
|
||||||
<Icon name="copy" size="sm" />
|
|
||||||
Copy Full Key
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="key-signing">
|
|
||||||
<h4>Sign Payload</h4>
|
|
||||||
<div className="form-group">
|
|
||||||
<textarea
|
|
||||||
value={signPayloads[key.id] || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSignPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Enter payload to sign..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSign(key.id)}
|
|
||||||
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
|
|
||||||
className="primary-button"
|
|
||||||
>
|
|
||||||
{signing[key.id] ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Signing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
Sign
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{signatures[key.id] && (
|
|
||||||
<div className="signature-result">
|
|
||||||
<label>Signature (base64):</label>
|
|
||||||
<code>{signatures[key.id]}</code>
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(signatures[key.id])}
|
|
||||||
className="secondary-button"
|
|
||||||
>
|
|
||||||
<Icon name="copy" size="sm" />
|
|
||||||
Copy Signature
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="key-verification">
|
|
||||||
<h4>Verify Signature</h4>
|
|
||||||
<div className="form-group">
|
|
||||||
<textarea
|
|
||||||
value={verifyPayloads[key.id] || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setVerifyPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Enter payload..."
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<textarea
|
|
||||||
value={verifySignatures[key.id] || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setVerifySignatures((prev) => ({ ...prev, [key.id]: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Enter base64 signature..."
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleVerify(key.id)}
|
|
||||||
disabled={
|
|
||||||
verifying[key.id] ||
|
|
||||||
!verifyPayloads[key.id]?.trim() ||
|
|
||||||
!verifySignatures[key.id]?.trim()
|
|
||||||
}
|
|
||||||
className="primary-button"
|
|
||||||
>
|
|
||||||
{verifying[key.id] ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Verifying...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="success" size="sm" />
|
|
||||||
Verify
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
|
|
||||||
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
|
|
||||||
{verifyResults[key.id] ? (
|
|
||||||
<>
|
|
||||||
<Icon name="success" size="sm" />
|
|
||||||
Signature is valid
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="error" size="sm" />
|
|
||||||
Signature is invalid
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+105
-564
@@ -1,571 +1,112 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useTerminalPage } from "../hooks/use-terminal-page";
|
||||||
import { TerminalComponent, type TerminalRef } from "../components/features/terminal/terminal";
|
import { MobileTerminalView } from "../components/features/terminal/MobileTerminalView";
|
||||||
import {
|
import { DesktopTerminalView } from "../components/features/terminal/DesktopTerminalView";
|
||||||
TerminalSessionTabs,
|
|
||||||
type TerminalSessionInfo,
|
|
||||||
} from "../components/features/terminal/terminal-session-tabs";
|
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import { SpecialKeysStrip } from "../components/features/terminal/special-keys-strip";
|
|
||||||
import { SpecialKeysPanel } from "../components/features/terminal/special-keys-panel";
|
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|
||||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
|
||||||
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
|
||||||
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
|
|
||||||
import type { TerminalSession } from "../api/terminal";
|
|
||||||
import type { ModifierKey } from "../hooks/use-special-keys";
|
|
||||||
|
|
||||||
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
|
||||||
sessions.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
status: s.status as TerminalSessionInfo["status"],
|
|
||||||
}));
|
|
||||||
|
|
||||||
type TerminalStatus =
|
|
||||||
| "connecting"
|
|
||||||
| "connected"
|
|
||||||
| "disconnected"
|
|
||||||
| "error"
|
|
||||||
| "resetting";
|
|
||||||
|
|
||||||
export const TerminalPage: React.FC = () => {
|
export const TerminalPage: React.FC = () => {
|
||||||
const { instanceId } = useParams<{
|
const {
|
||||||
instanceId: string;
|
instanceId,
|
||||||
}>();
|
navigate,
|
||||||
const navigate = useNavigate();
|
isMobile,
|
||||||
const isMobile = useMobileViewport();
|
isFullscreen,
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
setIsFullscreen,
|
||||||
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
terminalRefs,
|
||||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
headerAutoHide,
|
||||||
|
terminalStatuses,
|
||||||
|
showResetConfirm,
|
||||||
|
setShowResetConfirm,
|
||||||
|
showSpecialKeysPanel,
|
||||||
|
setShowSpecialKeysPanel,
|
||||||
|
activeModifier,
|
||||||
|
setActiveModifier,
|
||||||
|
isKeyboardOpen,
|
||||||
|
keyboardHeight,
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
handleFullscreenClick,
|
||||||
|
handleSelect,
|
||||||
|
handleClose,
|
||||||
|
handleCreate,
|
||||||
|
handleRename,
|
||||||
|
handleTerminalReady,
|
||||||
|
handleFontSizeChange,
|
||||||
|
handleSendKey,
|
||||||
|
handleReset,
|
||||||
|
sessionInfos,
|
||||||
|
} = useTerminalPage();
|
||||||
|
|
||||||
// Track terminal status and callbacks for unified fullscreen header
|
if (!instanceId) {
|
||||||
const [terminalStatuses, setTerminalStatuses] = useState<
|
return (
|
||||||
Record<string, TerminalStatus>
|
<section className="stack">
|
||||||
>({});
|
<h1>Terminal</h1>
|
||||||
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
<p className="muted">No instance ID provided.</p>
|
||||||
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
</section>
|
||||||
const focusInputRef = useRef<(() => void) | null>(null);
|
);
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
}
|
||||||
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
|
||||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
|
||||||
useVirtualKeyboard();
|
|
||||||
|
|
||||||
const {
|
const status = terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
|
||||||
sessions,
|
|
||||||
activeSessionId,
|
|
||||||
setActiveSessionId,
|
|
||||||
createSession,
|
|
||||||
closeSession,
|
|
||||||
renameSession,
|
|
||||||
resetSession,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
} = useTerminalSessions(instanceId ?? "");
|
|
||||||
|
|
||||||
// Auto-create default session if none exist after loading completes
|
if (isMobile) {
|
||||||
useEffect(() => {
|
return (
|
||||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
<MobileTerminalView
|
||||||
void createSession("Session 1");
|
instanceId={instanceId}
|
||||||
}
|
sessions={sessions}
|
||||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
sessionInfos={sessionInfos}
|
||||||
|
activeSessionId={activeSessionId ?? ""}
|
||||||
|
terminalRefs={terminalRefs}
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
|
loading={loading}
|
||||||
|
isKeyboardOpen={isKeyboardOpen}
|
||||||
|
keyboardHeight={keyboardHeight}
|
||||||
|
isVisible={headerAutoHide.isVisible}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
showSpecialKeysPanel={showSpecialKeysPanel}
|
||||||
|
onToggleHeader={headerAutoHide.toggle}
|
||||||
|
onNavigateBack={() => navigate("/sessions")}
|
||||||
|
onFontSizeChange={handleFontSizeChange}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onClose={handleClose}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
onRename={handleRename}
|
||||||
|
onTerminalReady={handleTerminalReady}
|
||||||
|
onSendKey={handleSendKey}
|
||||||
|
onModifierChange={setActiveModifier}
|
||||||
|
onShowSpecialKeys={() => setShowSpecialKeysPanel(true)}
|
||||||
|
onHideSpecialKeys={() => setShowSpecialKeysPanel(false)}
|
||||||
|
onKeepFocus={() => {
|
||||||
|
/* focus handled by ref */
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure refs map is kept in sync with sessions
|
return (
|
||||||
useEffect(() => {
|
<DesktopTerminalView
|
||||||
for (const session of sessions) {
|
instanceId={instanceId}
|
||||||
if (!terminalRefs.current[session.id]) {
|
sessions={sessions}
|
||||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
sessionInfos={sessionInfos}
|
||||||
}
|
activeSessionId={activeSessionId ?? ""}
|
||||||
}
|
terminalRefs={terminalRefs}
|
||||||
// Clean up refs for closed sessions
|
isFullscreen={isFullscreen}
|
||||||
const currentIds = new Set(sessions.map((s) => s.id));
|
status={status}
|
||||||
for (const id of Object.keys(terminalRefs.current)) {
|
error={error}
|
||||||
if (!currentIds.has(id)) {
|
loading={loading}
|
||||||
delete terminalRefs.current[id];
|
showResetConfirm={showResetConfirm}
|
||||||
}
|
onFullscreenClick={handleFullscreenClick}
|
||||||
}
|
onSelect={handleSelect}
|
||||||
}, [sessions]);
|
onClose={handleClose}
|
||||||
|
onCreate={handleCreate}
|
||||||
// Fit and focus active terminal when switching tabs
|
onRename={handleRename}
|
||||||
useEffect(() => {
|
onNavigateBack={() => navigate("/sessions")}
|
||||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
onToggleFullscreen={() => setIsFullscreen((p) => !p)}
|
||||||
const ref = terminalRefs.current[activeSessionId];
|
onFontSizeChange={handleFontSizeChange}
|
||||||
// Double rAF ensures layout has settled after the display:block switch
|
onShowResetConfirm={() => setShowResetConfirm(true)}
|
||||||
let raf1 = 0;
|
onHideResetConfirm={() => setShowResetConfirm(false)}
|
||||||
let raf2 = 0;
|
onReset={handleReset}
|
||||||
raf1 = requestAnimationFrame(() => {
|
onTerminalReady={handleTerminalReady}
|
||||||
raf2 = requestAnimationFrame(() => {
|
/>
|
||||||
ref.current?.fit();
|
);
|
||||||
ref.current?.focus();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(raf1);
|
|
||||||
cancelAnimationFrame(raf2);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [activeSessionId]);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
|
||||||
if (!isAltShift) return;
|
|
||||||
|
|
||||||
switch (e.key.toLowerCase()) {
|
|
||||||
case "n":
|
|
||||||
e.preventDefault();
|
|
||||||
if (sessions.length < 5) {
|
|
||||||
void createSession(`Session ${sessions.length + 1}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "w":
|
|
||||||
e.preventDefault();
|
|
||||||
if (
|
|
||||||
activeSessionId &&
|
|
||||||
window.confirm("Close this terminal session?")
|
|
||||||
) {
|
|
||||||
void closeSession(activeSessionId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "arrowleft":
|
|
||||||
e.preventDefault();
|
|
||||||
if (activeSessionId) {
|
|
||||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
|
||||||
if (idx > 0) {
|
|
||||||
setActiveSessionId(sessions[idx - 1].id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "arrowright":
|
|
||||||
e.preventDefault();
|
|
||||||
if (activeSessionId) {
|
|
||||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
|
||||||
if (idx < sessions.length - 1) {
|
|
||||||
setActiveSessionId(sessions[idx + 1].id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "r":
|
|
||||||
e.preventDefault();
|
|
||||||
if (activeSessionId) {
|
|
||||||
void resetSession(activeSessionId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "f":
|
|
||||||
e.preventDefault();
|
|
||||||
setIsFullscreen((prev) => !prev);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [
|
|
||||||
sessions,
|
|
||||||
activeSessionId,
|
|
||||||
createSession,
|
|
||||||
closeSession,
|
|
||||||
resetSession,
|
|
||||||
setActiveSessionId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Keep screen awake while terminal is open
|
|
||||||
useEffect(() => {
|
|
||||||
let wakeLock: WakeLockSentinel | null = null;
|
|
||||||
|
|
||||||
const requestWakeLock = async () => {
|
|
||||||
try {
|
|
||||||
if ("wakeLock" in navigator) {
|
|
||||||
wakeLock = await navigator.wakeLock.request("screen");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Wake lock may be denied; silently ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void requestWakeLock();
|
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === "visible") {
|
|
||||||
void requestWakeLock();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
wakeLock?.release().catch(() => {});
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Lock page scroll on mobile terminal so swipes scroll the terminal buffer,
|
|
||||||
// not the page.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMobile) return;
|
|
||||||
document.documentElement.classList.add("terminal-page-open");
|
|
||||||
document.body.classList.add("terminal-page-open");
|
|
||||||
return () => {
|
|
||||||
document.documentElement.classList.remove("terminal-page-open");
|
|
||||||
document.body.classList.remove("terminal-page-open");
|
|
||||||
};
|
|
||||||
}, [isMobile]);
|
|
||||||
|
|
||||||
// Click outside terminal content/header to exit fullscreen
|
|
||||||
const handleFullscreenClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLElement>) => {
|
|
||||||
if (!isFullscreen) return;
|
|
||||||
const target = e.target as Node;
|
|
||||||
const current = e.currentTarget as HTMLElement;
|
|
||||||
const content = current.querySelector(".terminal-page-content");
|
|
||||||
const header = current.querySelector(".terminal-fullscreen-header");
|
|
||||||
if (content?.contains(target) || header?.contains(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsFullscreen(false);
|
|
||||||
},
|
|
||||||
[isFullscreen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(sessionId: string) => {
|
|
||||||
setActiveSessionId(sessionId);
|
|
||||||
},
|
|
||||||
[setActiveSessionId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(
|
|
||||||
async (sessionId: string) => {
|
|
||||||
await closeSession(sessionId);
|
|
||||||
},
|
|
||||||
[closeSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreate = useCallback(() => {
|
|
||||||
void createSession(`Session ${sessions.length + 1}`);
|
|
||||||
}, [createSession, sessions.length]);
|
|
||||||
|
|
||||||
const handleRename = useCallback(
|
|
||||||
(sessionId: string, newName: string) => {
|
|
||||||
void renameSession(sessionId, newName);
|
|
||||||
},
|
|
||||||
[renameSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTerminalReady = useCallback(
|
|
||||||
(
|
|
||||||
sendData: (data: string) => void,
|
|
||||||
status: TerminalStatus,
|
|
||||||
focusInput: () => void,
|
|
||||||
changeFontSize: (delta: number) => void,
|
|
||||||
) => {
|
|
||||||
setTerminalStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[activeSessionId ?? "default"]: status,
|
|
||||||
}));
|
|
||||||
sendDataRef.current = sendData;
|
|
||||||
focusInputRef.current = focusInput;
|
|
||||||
changeFontSizeRef.current = changeFontSize;
|
|
||||||
},
|
|
||||||
[activeSessionId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFontSizeChange = useCallback((delta: number) => {
|
|
||||||
changeFontSizeRef.current?.(delta);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSendKey = useCallback((data: string) => {
|
|
||||||
sendDataRef.current?.(data);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
|
||||||
terminalRefs.current[activeSessionId].current?.reset();
|
|
||||||
}
|
|
||||||
}, [activeSessionId]);
|
|
||||||
|
|
||||||
if (!instanceId) {
|
|
||||||
return (
|
|
||||||
<section className="stack">
|
|
||||||
<h1>Terminal</h1>
|
|
||||||
<p className="muted">No instance ID provided.</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionInfos = SESSIONS_TO_INFO(sessions);
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
|
||||||
const status =
|
|
||||||
terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
|
|
||||||
>
|
|
||||||
{/* Overlay status bar — floats over terminal, never resizes it */}
|
|
||||||
<div
|
|
||||||
className={`mobile-terminal-overlay ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="mobile-terminal-toolbar">
|
|
||||||
<div className="mobile-terminal-toolbar-left">
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => navigate("/sessions")}
|
|
||||||
type="button"
|
|
||||||
aria-label="Back"
|
|
||||||
>
|
|
||||||
<Icon name="arrow-left" size="sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-terminal-toolbar-center">
|
|
||||||
<span className="mobile-terminal-title">
|
|
||||||
{activeSession?.name || "Terminal"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`mobile-terminal-status status-dot ${status}`}
|
|
||||||
aria-label={`Connection status: ${status}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-terminal-toolbar-right">
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => handleFontSizeChange(-1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Decrease font size"
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "0.75rem" }}>A-</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => handleFontSizeChange(1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Increase font size"
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "1rem" }}>A+</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => navigate("/sessions")}
|
|
||||||
type="button"
|
|
||||||
aria-label="Exit terminal"
|
|
||||||
>
|
|
||||||
<Icon name="close" size="sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-terminal-overlay-tabs">
|
|
||||||
<TerminalSessionTabs
|
|
||||||
sessions={sessionInfos}
|
|
||||||
activeSessionId={activeSessionId ?? ""}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onClose={handleClose}
|
|
||||||
onCreate={handleCreate}
|
|
||||||
onRename={handleRename}
|
|
||||||
isMobile={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Terminal content — always fills full viewport */}
|
|
||||||
<div
|
|
||||||
className="terminal-page-content mobile-full"
|
|
||||||
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
|
|
||||||
onClick={() => headerAutoHide.toggle()}
|
|
||||||
>
|
|
||||||
{error && <div className="terminal-error-banner">{error}</div>}
|
|
||||||
{sessions
|
|
||||||
.filter((session) => session.id === activeSessionId)
|
|
||||||
.map((session) => (
|
|
||||||
<div key={session.id} className="terminal-instance active">
|
|
||||||
<TerminalComponent
|
|
||||||
ref={terminalRefs.current[session.id]}
|
|
||||||
instanceId={instanceId}
|
|
||||||
sessionId={session.id}
|
|
||||||
onClose={() => handleClose(session.id)}
|
|
||||||
isMobile={true}
|
|
||||||
showControls={false}
|
|
||||||
activeModifier={activeModifier}
|
|
||||||
onModifierChange={setActiveModifier}
|
|
||||||
onTerminalReady={handleTerminalReady}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sessions.length === 0 && !loading && (
|
|
||||||
<div className="terminal-empty-state">
|
|
||||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SpecialKeysStrip
|
|
||||||
onSend={handleSendKey}
|
|
||||||
isVisible={!showSpecialKeysPanel}
|
|
||||||
onMoreClick={() => setShowSpecialKeysPanel(true)}
|
|
||||||
onKeepFocus={() => focusInputRef.current?.()}
|
|
||||||
activeModifier={activeModifier}
|
|
||||||
onModifierChange={setActiveModifier}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SpecialKeysPanel
|
|
||||||
onSend={handleSendKey}
|
|
||||||
isOpen={showSpecialKeysPanel}
|
|
||||||
onClose={() => setShowSpecialKeysPanel(false)}
|
|
||||||
onKeepFocus={() => focusInputRef.current?.()}
|
|
||||||
activeModifier={activeModifier}
|
|
||||||
onModifierChange={setActiveModifier}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
|
|
||||||
onClick={handleFullscreenClick}
|
|
||||||
>
|
|
||||||
{!isFullscreen && (
|
|
||||||
<div className="terminal-page-header">
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<h1>Terminal</h1>
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => setIsFullscreen((p) => !p)}
|
|
||||||
type="button"
|
|
||||||
title="Toggle fullscreen (Alt+Shift+F)"
|
|
||||||
>
|
|
||||||
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isFullscreen ? (
|
|
||||||
<div className="terminal-fullscreen-header">
|
|
||||||
<div className="terminal-fullscreen-header-tabs">
|
|
||||||
<TerminalSessionTabs
|
|
||||||
sessions={sessionInfos}
|
|
||||||
activeSessionId={activeSessionId ?? ""}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onClose={handleClose}
|
|
||||||
onCreate={handleCreate}
|
|
||||||
onRename={handleRename}
|
|
||||||
isMobile={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="terminal-fullscreen-header-controls">
|
|
||||||
<span
|
|
||||||
className={`terminal-fullscreen-status status-dot ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
|
|
||||||
aria-label={`Terminal status: ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="terminal-header-button"
|
|
||||||
onClick={() => handleFontSizeChange(-1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Decrease font size"
|
|
||||||
>
|
|
||||||
A-
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-header-button"
|
|
||||||
onClick={() => handleFontSizeChange(1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Increase font size"
|
|
||||||
>
|
|
||||||
A+
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-header-button"
|
|
||||||
onClick={() => setShowResetConfirm(true)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Reset terminal"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-close"
|
|
||||||
onClick={() => setIsFullscreen(false)}
|
|
||||||
type="button"
|
|
||||||
title="Exit fullscreen (Esc)"
|
|
||||||
>
|
|
||||||
Exit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showResetConfirm && (
|
|
||||||
<div className="terminal-reset-confirm">
|
|
||||||
<div className="terminal-reset-confirm-content">
|
|
||||||
<p>
|
|
||||||
Reset terminal? This will kill the current shell session and
|
|
||||||
start fresh.
|
|
||||||
</p>
|
|
||||||
<div className="terminal-reset-confirm-buttons">
|
|
||||||
<button
|
|
||||||
className="terminal-reset-confirm-button cancel"
|
|
||||||
onClick={() => setShowResetConfirm(false)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-reset-confirm-button confirm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowResetConfirm(false);
|
|
||||||
handleReset();
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<TerminalSessionTabs
|
|
||||||
sessions={sessionInfos}
|
|
||||||
activeSessionId={activeSessionId ?? ""}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onClose={handleClose}
|
|
||||||
onCreate={handleCreate}
|
|
||||||
onRename={handleRename}
|
|
||||||
isMobile={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="terminal-page-content">
|
|
||||||
{error && <div className="terminal-error-banner">{error}</div>}
|
|
||||||
{sessions
|
|
||||||
.filter((session) => session.id === activeSessionId)
|
|
||||||
.map((session) => (
|
|
||||||
<div key={session.id} className="terminal-instance active">
|
|
||||||
<TerminalComponent
|
|
||||||
ref={terminalRefs.current[session.id]}
|
|
||||||
instanceId={instanceId}
|
|
||||||
sessionId={session.id}
|
|
||||||
onClose={() => handleClose(session.id)}
|
|
||||||
isMobile={false}
|
|
||||||
showControls={!isFullscreen}
|
|
||||||
onTerminalReady={handleTerminalReady}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sessions.length === 0 && !loading && (
|
|
||||||
<div className="terminal-empty-state">
|
|
||||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -150,8 +150,16 @@ function MobileTabBar({
|
|||||||
/* ─── Files Tab ─── */
|
/* ─── Files Tab ─── */
|
||||||
|
|
||||||
function FilesTab({ workspaceId }: { workspaceId: string }) {
|
function FilesTab({ workspaceId }: { workspaceId: string }) {
|
||||||
const { entries, content, loadFile, saveFile, loading, error } =
|
const {
|
||||||
useWorkspaceFiles(workspaceId);
|
entries,
|
||||||
|
content,
|
||||||
|
currentPath,
|
||||||
|
loadFile,
|
||||||
|
saveFile,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
navigateTo,
|
||||||
|
} = useWorkspaceFiles(workspaceId);
|
||||||
const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId);
|
const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId);
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
const [editContent, setEditContent] = useState<string | null>(null);
|
const [editContent, setEditContent] = useState<string | null>(null);
|
||||||
@@ -159,13 +167,28 @@ function FilesTab({ workspaceId }: { workspaceId: string }) {
|
|||||||
const [commitMessage, setCommitMessage] = useState("");
|
const [commitMessage, setCommitMessage] = useState("");
|
||||||
|
|
||||||
const handleSelect = (entry: FileEntry) => {
|
const handleSelect = (entry: FileEntry) => {
|
||||||
if (entry.type === "directory") return;
|
if (entry.type === "directory") {
|
||||||
|
setSelectedPath(null);
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditContent(null);
|
||||||
|
navigateTo(entry.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSelectedPath(entry.path);
|
setSelectedPath(entry.path);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setEditContent(null);
|
setEditContent(null);
|
||||||
loadFile(entry.path);
|
loadFile(entry.path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigateUp = () => {
|
||||||
|
if (!currentPath) return;
|
||||||
|
const parentPath = currentPath.split("/").slice(0, -1).join("/");
|
||||||
|
navigateTo(parentPath);
|
||||||
|
setSelectedPath(null);
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditContent(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
if (content !== null) {
|
if (content !== null) {
|
||||||
setEditContent(content);
|
setEditContent(content);
|
||||||
@@ -224,6 +247,15 @@ function FilesTab({ workspaceId }: { workspaceId: string }) {
|
|||||||
)}
|
)}
|
||||||
<div className="files-split">
|
<div className="files-split">
|
||||||
<div className="file-tree">
|
<div className="file-tree">
|
||||||
|
{currentPath && (
|
||||||
|
<button
|
||||||
|
className="tree-entry tree-up"
|
||||||
|
onClick={navigateUp}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="folder" size="sm" /> ..
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{loading && <p className="muted">Loading...</p>}
|
{loading && <p className="muted">Loading...</p>}
|
||||||
{error && <p className="error-text">{error}</p>}
|
{error && <p className="error-text">{error}</p>}
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
@@ -343,8 +375,8 @@ function ToolsTab({ workspace }: { workspace: Workspace }) {
|
|||||||
{instance.url && (
|
{instance.url && (
|
||||||
<a
|
<a
|
||||||
href={instance.url}
|
href={instance.url}
|
||||||
target="_blank"
|
target={`instance-${instance.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -2,14 +2,25 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
import { useWorkspaces } from "../hooks/use-workspaces";
|
import { useWorkspaces } from "../hooks/use-workspaces";
|
||||||
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
|
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
|
||||||
import { WorkspaceCard } from "../components/features/workspace/workspace-card";
|
import { WorkspaceCard } from "../components/features/workspace/workspace-card";
|
||||||
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
|
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
|
||||||
|
import { MobileListView } from "../components/features/mobile/mobile-list-view";
|
||||||
|
import { MobileDetailView } from "../components/features/mobile/mobile-detail-view";
|
||||||
|
import { MobileFAB } from "../components/features/mobile/mobile-fab";
|
||||||
import { ToolStarter } from "../components/features/tool/tool-starter";
|
import { ToolStarter } from "../components/features/tool/tool-starter";
|
||||||
import type { Workspace } from "../types/workspace";
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
|
type MobileView = "list" | "detail" | "create";
|
||||||
|
|
||||||
export function WorkspacesPage() {
|
export function WorkspacesPage() {
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||||
|
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
|
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
|
||||||
|
|
||||||
@@ -28,6 +39,113 @@ export function WorkspacesPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Mobile views ── */
|
||||||
|
if (isMobile) {
|
||||||
|
if (mobileView === "create") {
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<WorkspaceCreateForm
|
||||||
|
onSubmit={async () => {
|
||||||
|
setMobileView("list");
|
||||||
|
await refresh();
|
||||||
|
}}
|
||||||
|
onCancel={() => setMobileView("list")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileView === "detail" && selectedWorkspace) {
|
||||||
|
const ws = selectedWorkspace;
|
||||||
|
return (
|
||||||
|
<MobileDetailView
|
||||||
|
title={ws.name}
|
||||||
|
subtitle={`${ws.project_name} / ${ws.repo_name}`}
|
||||||
|
fields={[
|
||||||
|
{ label: "Branch", value: ws.branch },
|
||||||
|
{ label: "Status", value: ws.status },
|
||||||
|
{ label: "Path", value: ws.path },
|
||||||
|
{ label: "Instances", value: ws.instance_count },
|
||||||
|
{ label: "Last Sync", value: ws.last_sync_at ?? "Never" },
|
||||||
|
{ label: "Created", value: ws.created_at },
|
||||||
|
]}
|
||||||
|
onEdit={() => {
|
||||||
|
setStartWorkspace(ws);
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
void handleDelete(ws);
|
||||||
|
setMobileView("list");
|
||||||
|
setSelectedWorkspace(null);
|
||||||
|
}}
|
||||||
|
onBack={() => {
|
||||||
|
setMobileView("list");
|
||||||
|
setSelectedWorkspace(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile list view */
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<div className="mobile-page-header">
|
||||||
|
<h1>Workspaces</h1>
|
||||||
|
<span className="muted">
|
||||||
|
{workspaces.length} workspace{workspaces.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
{loading && workspaces.length === 0 ? (
|
||||||
|
<div className="loading-state">Loading workspaces...</div>
|
||||||
|
) : (
|
||||||
|
<MobileListView
|
||||||
|
items={workspaces.map((ws) => ({
|
||||||
|
id: ws.id,
|
||||||
|
title: ws.name,
|
||||||
|
subtitle: `${ws.project_name} · ${ws.repo_name} · ${ws.branch}`,
|
||||||
|
status: ws.status,
|
||||||
|
}))}
|
||||||
|
onItemClick={(id) => {
|
||||||
|
const ws = workspaces.find((w) => w.id === id);
|
||||||
|
if (ws) {
|
||||||
|
setSelectedWorkspace(ws);
|
||||||
|
setMobileView("detail");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
emptyMessage="No workspaces yet"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MobileFAB
|
||||||
|
onClick={() => setMobileView("create")}
|
||||||
|
label="Create workspace"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{startWorkspace && (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
onClick={() => setStartWorkspace(null)}
|
||||||
|
>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Start Tool</h3>
|
||||||
|
<ToolStarter
|
||||||
|
workspace={startWorkspace}
|
||||||
|
onStarted={() => {
|
||||||
|
setStartWorkspace(null);
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
onCancel={() => setStartWorkspace(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop view ── */
|
||||||
return (
|
return (
|
||||||
<div className="page workspaces-page">
|
<div className="page workspaces-page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ export function NotificationProvider({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Silently log other errors; next cycle proceeds
|
// Silently log other errors; next cycle proceeds
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("Notification unread count poll failed", err);
|
console.error("Notification unread count poll failed", err);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -111,7 +110,6 @@ export function NotificationProvider({
|
|||||||
listIntervalRef.current = null;
|
listIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("Notification list poll failed", err);
|
console.error("Notification list poll failed", err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import type { InstanceEventPayload } from "../types/events";
|
||||||
|
|
||||||
|
export type OperationType =
|
||||||
|
| "create"
|
||||||
|
| "start"
|
||||||
|
| "stop"
|
||||||
|
| "restart"
|
||||||
|
| "delete"
|
||||||
|
| "recreate-tunnel";
|
||||||
|
|
||||||
|
export type OperationStatus = "pending" | "active" | "success" | "error";
|
||||||
|
|
||||||
|
export interface Operation {
|
||||||
|
id: string;
|
||||||
|
type: OperationType;
|
||||||
|
instanceId: string;
|
||||||
|
displayName: string;
|
||||||
|
status: OperationStatus;
|
||||||
|
message: string;
|
||||||
|
step: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionOperationsContextType {
|
||||||
|
operations: Operation[];
|
||||||
|
startOperation: (
|
||||||
|
type: OperationType,
|
||||||
|
instanceId: string,
|
||||||
|
displayName: string,
|
||||||
|
) => string;
|
||||||
|
updateOperationFromEvent: (event: InstanceEventPayload) => void;
|
||||||
|
completeOperation: (
|
||||||
|
instanceId: string,
|
||||||
|
type: OperationType,
|
||||||
|
outcome: "success" | "error",
|
||||||
|
message?: string,
|
||||||
|
) => void;
|
||||||
|
dismissOperation: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionOperationsContext =
|
||||||
|
createContext<SessionOperationsContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
let operationIdCounter = 0;
|
||||||
|
|
||||||
|
function actionLabel(type: OperationType): string {
|
||||||
|
switch (type) {
|
||||||
|
case "create":
|
||||||
|
return "Creating";
|
||||||
|
case "start":
|
||||||
|
return "Starting";
|
||||||
|
case "stop":
|
||||||
|
return "Stopping";
|
||||||
|
case "restart":
|
||||||
|
return "Restarting";
|
||||||
|
case "delete":
|
||||||
|
return "Deleting";
|
||||||
|
case "recreate-tunnel":
|
||||||
|
return "Recreating tunnel";
|
||||||
|
default:
|
||||||
|
return "Working";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageForEvent(
|
||||||
|
type: OperationType,
|
||||||
|
event: InstanceEventPayload,
|
||||||
|
): string {
|
||||||
|
if (event.message) return event.message;
|
||||||
|
|
||||||
|
switch (event.event) {
|
||||||
|
case "instance.created":
|
||||||
|
return "Created";
|
||||||
|
case "instance.started":
|
||||||
|
return "Starting container";
|
||||||
|
case "instance.restarted":
|
||||||
|
return "Restarting container";
|
||||||
|
case "instance.stopped":
|
||||||
|
return "Stopped";
|
||||||
|
case "instance.deleted":
|
||||||
|
return "Deleted";
|
||||||
|
case "instance.health_changed":
|
||||||
|
if (event.status === "running") return "Running";
|
||||||
|
if (event.status === "unhealthy") return "Unhealthy";
|
||||||
|
return `Status: ${event.status ?? event.event}`;
|
||||||
|
case "instance.error":
|
||||||
|
return event.message ?? "Error";
|
||||||
|
default:
|
||||||
|
return event.message ?? actionLabel(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepForEvent(event: InstanceEventPayload): number {
|
||||||
|
switch (event.event) {
|
||||||
|
case "instance.created":
|
||||||
|
return 1;
|
||||||
|
case "instance.started":
|
||||||
|
case "instance.restarted":
|
||||||
|
return 2;
|
||||||
|
case "instance.health_changed":
|
||||||
|
if (event.status === "running") return 4;
|
||||||
|
if (event.status === "unhealthy") return 4;
|
||||||
|
return 3;
|
||||||
|
case "instance.error":
|
||||||
|
return 4;
|
||||||
|
case "instance.stopped":
|
||||||
|
return 4;
|
||||||
|
case "instance.deleted":
|
||||||
|
return 4;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionOperationsProvider = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) => {
|
||||||
|
const [operations, setOperations] = useState<Operation[]>([]);
|
||||||
|
|
||||||
|
const startOperation = useCallback(
|
||||||
|
(type: OperationType, instanceId: string, displayName: string): string => {
|
||||||
|
const id = `op-${++operationIdCounter}`;
|
||||||
|
const operation: Operation = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
instanceId,
|
||||||
|
displayName,
|
||||||
|
status: "pending",
|
||||||
|
message: actionLabel(type),
|
||||||
|
step: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
setOperations((prev) => [operation, ...prev].slice(0, 20));
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateOperationFromEvent = useCallback(
|
||||||
|
(event: InstanceEventPayload) => {
|
||||||
|
setOperations((prev) => {
|
||||||
|
const matches = prev.filter(
|
||||||
|
(op) => op.instanceId === event.instance_id && op.status !== "success",
|
||||||
|
);
|
||||||
|
if (matches.length === 0) return prev;
|
||||||
|
|
||||||
|
const updated = new Map<string, Operation>();
|
||||||
|
for (const op of prev) updated.set(op.id, op);
|
||||||
|
|
||||||
|
for (const op of matches) {
|
||||||
|
const nextStep = stepForEvent(event);
|
||||||
|
const message = messageForEvent(op.type, event);
|
||||||
|
let nextStatus: OperationStatus = op.status;
|
||||||
|
|
||||||
|
if (event.event === "instance.error") {
|
||||||
|
nextStatus = "error";
|
||||||
|
} else if (
|
||||||
|
event.event === "instance.health_changed" &&
|
||||||
|
event.status === "running"
|
||||||
|
) {
|
||||||
|
nextStatus = "success";
|
||||||
|
} else if (event.event === "instance.deleted") {
|
||||||
|
nextStatus = "success";
|
||||||
|
} else if (event.event === "instance.stopped") {
|
||||||
|
nextStatus = "success";
|
||||||
|
} else if (nextStatus === "pending") {
|
||||||
|
nextStatus = "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.set(op.id, {
|
||||||
|
...op,
|
||||||
|
status: nextStatus,
|
||||||
|
message,
|
||||||
|
step: Math.max(op.step, nextStep),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(updated.values());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const completeOperation = useCallback(
|
||||||
|
(
|
||||||
|
instanceId: string,
|
||||||
|
type: OperationType,
|
||||||
|
outcome: "success" | "error",
|
||||||
|
message?: string,
|
||||||
|
) => {
|
||||||
|
setOperations((prev) => {
|
||||||
|
const match = prev.find(
|
||||||
|
(op) => op.instanceId === instanceId && op.type === type,
|
||||||
|
);
|
||||||
|
if (!match) return prev;
|
||||||
|
return prev.map((op) =>
|
||||||
|
op.id === match.id
|
||||||
|
? {
|
||||||
|
...op,
|
||||||
|
status: outcome,
|
||||||
|
message:
|
||||||
|
message ?? (outcome === "success" ? "Done" : "Failed"),
|
||||||
|
step: 4,
|
||||||
|
}
|
||||||
|
: op,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismissOperation = useCallback((id: string) => {
|
||||||
|
setOperations((prev) => prev.filter((op) => op.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
operations,
|
||||||
|
startOperation,
|
||||||
|
updateOperationFromEvent,
|
||||||
|
completeOperation,
|
||||||
|
dismissOperation,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
operations,
|
||||||
|
startOperation,
|
||||||
|
updateOperationFromEvent,
|
||||||
|
completeOperation,
|
||||||
|
dismissOperation,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionOperationsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SessionOperationsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSessionOperations = (): SessionOperationsContextType => {
|
||||||
|
const context = useContext(SessionOperationsContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"useSessionOperations must be used within a SessionOperationsProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -1,35 +1,82 @@
|
|||||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
export interface Session {
|
import { getUserSessions } from "../api/sessions";
|
||||||
id: string;
|
import type { Session } from "../api/sessions";
|
||||||
display_name: string;
|
|
||||||
tool_type_name: string;
|
|
||||||
tool_icon: string;
|
|
||||||
tool_type_interfaces: string[];
|
|
||||||
repository_name: string;
|
|
||||||
repository_id: string;
|
|
||||||
project_name: string;
|
|
||||||
project_id: string;
|
|
||||||
status: string;
|
|
||||||
url: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionsContextType {
|
export interface SessionsContextType {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
setAllSessions: (sessions: Session[]) => void;
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refreshSessions: () => Promise<void>;
|
||||||
|
addOrUpdateSession: (session: Session) => void;
|
||||||
|
removeSession: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionsContext = createContext<SessionsContextType | undefined>(undefined);
|
const SessionsContext = createContext<SessionsContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
|
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
const setAllSessions = useCallback((newSessions: Session[]) => {
|
const refreshSessions = useCallback(async () => {
|
||||||
setSessions(newSessions);
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await getUserSessions();
|
||||||
|
setSessions(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error("Failed to load sessions"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const addOrUpdateSession = useCallback((session: Session) => {
|
||||||
|
setSessions((prev) => {
|
||||||
|
const existing = prev.find((s) => s.id === session.id);
|
||||||
|
if (existing) {
|
||||||
|
return prev.map((s) => (s.id === session.id ? { ...s, ...session } : s));
|
||||||
|
}
|
||||||
|
return [session, ...prev];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeSession = useCallback((id: string) => {
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshSessions();
|
||||||
|
// Poll every 30 seconds to reconcile shared state with the server.
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void refreshSessions();
|
||||||
|
}, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [refreshSessions]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
sessions,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refreshSessions,
|
||||||
|
addOrUpdateSession,
|
||||||
|
removeSession,
|
||||||
|
}),
|
||||||
|
[sessions, isLoading, error, refreshSessions, addOrUpdateSession, removeSession],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
|
<SessionsContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</SessionsContext.Provider>
|
</SessionsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
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,164 @@
|
|||||||
|
/* ─── 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-add-repo {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline radio buttons for repository creation mode */
|
||||||
|
.repo-mode-radios {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-mode-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-mode-label input[type="radio"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--brand);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -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,150 @@
|
|||||||
|
/* Session card rename */
|
||||||
|
.session-card-rename input {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-context {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-context strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 options dropdown */
|
||||||
|
.session-options {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-options-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 0.25rem);
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 12rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: dropdown-in 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdown-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-option-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-option-item:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-option-item.danger-text {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-option-item.danger-text:hover {
|
||||||
|
background: var(--danger-subtle, rgba(239, 68, 68, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 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,126 @@
|
|||||||
|
/** Workspaces page styles */
|
||||||
|
|
||||||
|
/* Desktop */
|
||||||
|
.workspaces-page {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaces-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspace card */
|
||||||
|
.workspace-card {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-link {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-meta {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-meta p {
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ready {
|
||||||
|
background: var(--success-light);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-syncing {
|
||||||
|
background: var(--warning-light);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background: var(--danger-light);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-10) var(--space-4);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.workspaces-page {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaces-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-04
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
After the backend-frontend refactoring (commits `0591b00` through `8c7affc`), the codebase gained proper directory structure but several files grew into monoliths. The `main` branch (pre-refactor baseline at `5ed5e1c`) kept pages thin by delegating to extracted components. On `dev`, new features were added inline, causing pages and routers to absorb responsibilities that belong in components or services.
|
||||||
|
|
||||||
|
### Problem Files (Frontend)
|
||||||
|
|
||||||
|
| File | Lines | Problem |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `pages/ToolWorkshopPage.tsx` | **1,269** | Merged 3 tab components inline (ToolTypes, ToolConfigs, ConfigFolders) |
|
||||||
|
| `pages/ConfigProfilesPage.tsx` | **1,611** | List, detail, edit, create, and mobile views all in one file |
|
||||||
|
| `pages/TerminalPage.tsx` | **571** | Session tabs, keyboard shortcuts, fullscreen, mobile overlay, special keys all inline |
|
||||||
|
| `pages/RepoWorkspacePage.tsx` | **505** | File editor, git toolbar, workspace header, sidebar logic inline |
|
||||||
|
| `pages/SettingsPage.tsx` | **284** | Settings nav + multiple setting sections inline |
|
||||||
|
| `pages/SshKeysPage.tsx` | **277** | List and create inline |
|
||||||
|
|
||||||
|
### Problem Files (Backend)
|
||||||
|
|
||||||
|
| File | Lines | Problem |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `api/tool/tool_instances.py` | **2,900** | CRUD, Docker lifecycle, WebSocket proxy, terminal sessions, instance proxy all in one router |
|
||||||
|
| `api/project/git_repositories.py` | **1,588** | HTTP endpoints mixed with git command orchestration |
|
||||||
|
| `api/config/config_profiles.py` | **842** | CRUD + validation + resolver + mount/include management |
|
||||||
|
|
||||||
|
### What `main` Did Differently
|
||||||
|
|
||||||
|
`main` at `5ed5e1c`:
|
||||||
|
- `ToolWorkshopPage.tsx` = **77 lines** (just a tab switcher, tabs imported from `features/tool-workshop/`)
|
||||||
|
- `TerminalPage.tsx` = **38 lines** (just a wrapper around `TerminalComponent`)
|
||||||
|
- `api/tool_instances.py` = **284 lines** (HTTP endpoints only)
|
||||||
|
- `api/terminal.py` = **158 lines** (separate WebSocket router)
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
Restore the **thin-page / thin-router / fat-component** pattern from `main`, adapted to current `dev` features:
|
||||||
|
|
||||||
|
1. **Frontend page extraction** — Split monolithic pages into:
|
||||||
|
- Page shell (orchestrator, 50-150 lines)
|
||||||
|
- Tab components (for tabbed pages)
|
||||||
|
- List / Detail / Edit / Create components (for CRUD pages)
|
||||||
|
- Mobile-specific views (extracted, not inline)
|
||||||
|
|
||||||
|
2. **Backend router slimming** — Split `tool_instances.py` into:
|
||||||
|
- `tool_instances.py` — CRUD endpoints only
|
||||||
|
- `tool_lifecycle.py` — Start/stop/restart/delete logic
|
||||||
|
- Move terminal WebSocket back to dedicated `terminal.py`
|
||||||
|
|
||||||
|
3. **Git repository router** — Extract git command orchestration into `services/git/`
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- None (pure structural refactor)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `frontend-structure`: Pages become orchestrators; components carry the UI logic
|
||||||
|
- `backend-structure`: Routers become HTTP-only; services carry business logic
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Frontend**: New `features/tool-workshop/` tab components, new `features/config-profiles/` components, `features/terminal/` session manager, etc.
|
||||||
|
- **Backend**: New `api/tool/tool_lifecycle.py`, `api/tool/terminal.py`, slimmer `api/tool/tool_instances.py`
|
||||||
|
- **Tests**: Test files may need import path updates (component moved → test follows)
|
||||||
|
|
||||||
|
## Exclusions (Already Done / Out of Scope)
|
||||||
|
|
||||||
|
- Directory structure already exists (`features/`, `services/`, etc.)
|
||||||
|
- Schema extraction already done (`schemas/` subpackages)
|
||||||
|
- Model subpackages already done (`models/` subpackages)
|
||||||
|
- API router subpackages already done (`api/tool/`, `api/project/`, etc.)
|
||||||
|
- File naming already done (kebab-case APIs, PascalCase pages)
|
||||||
|
- No behavioral changes to any endpoint or UI flow
|
||||||
|
- No database schema changes
|
||||||
|
- No new features
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
## Scope
|
||||||
|
|
||||||
|
This change is a **pure structural refactoring** to split monolithic pages and routers into focused components and services. No API contracts, database schemas, or user-facing behaviors change.
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
#### 1. Frontend Page Extraction
|
||||||
|
|
||||||
|
Split the following pages into a thin page shell + extracted components:
|
||||||
|
|
||||||
|
**`pages/ToolWorkshopPage.tsx` (1,269 → ~80 lines)**
|
||||||
|
- Extract `ToolTypesTab` → `components/features/tool-workshop/ToolTypesTab.tsx`
|
||||||
|
- Extract `ToolConfigsTab` → `components/features/tool-workshop/ToolConfigsTab.tsx`
|
||||||
|
- Extract `ConfigFoldersTab` → `components/features/tool-workshop/ConfigFoldersTab.tsx`
|
||||||
|
- Page becomes: tab switcher only, imports the 3 tabs
|
||||||
|
|
||||||
|
**`pages/ConfigProfilesPage.tsx` (1,611 → ~80 lines)**
|
||||||
|
- Extract `ConfigProfileListView` → list view + mobile list view
|
||||||
|
- Extract `ConfigProfileDetailView` → detail view with edit toggle
|
||||||
|
- Extract `ConfigProfileEditForm` → edit/create form
|
||||||
|
- Extract `ConfigProfileMobileView` → mobile view state machine wrapper
|
||||||
|
- Page becomes: router between list/detail/edit views
|
||||||
|
|
||||||
|
**`pages/TerminalPage.tsx` (571 → ~80 lines)**
|
||||||
|
- Extract `TerminalSessionManager` → session tabs + auto-create logic
|
||||||
|
- Extract `TerminalKeyboardShortcuts` → shortcut handler hook (already exists, just use it)
|
||||||
|
- Extract `MobileTerminalOverlay` → mobile overlay toolbar + tabs
|
||||||
|
- Page becomes: choose between desktop (`TerminalComponent` + `TerminalSessionTabs`) and mobile (`MobileTerminalOverlay` + `TerminalComponent`) wrappers
|
||||||
|
|
||||||
|
**`pages/SettingsPage.tsx` (284 → ~80 lines)**
|
||||||
|
- Extract `SettingsNavigation` → settings nav sidebar
|
||||||
|
- Extract `GeneralSettingsTab`, `SSHKeysTab` (already separate pages, but move sections into components if inline)
|
||||||
|
- Page becomes: nav + `<Outlet>` for nested routes
|
||||||
|
|
||||||
|
**`pages/SshKeysPage.tsx` (277 → ~80 lines)**
|
||||||
|
- Extract `SSHKeyList` → list with actions
|
||||||
|
- Extract `SSHKeyCreateForm` → create form
|
||||||
|
- Page becomes: layout wrapper + conditionally render list or form
|
||||||
|
|
||||||
|
**`pages/RepoWorkspacePage.tsx` (505 → ~150 lines)**
|
||||||
|
- Extract `WorkspaceLayout` → sidebar + main content layout
|
||||||
|
- Page becomes: data loader + layout wrapper
|
||||||
|
|
||||||
|
**`pages/ProjectsPage.tsx` (433 → ~100 lines)**
|
||||||
|
- Extract `ProjectList` → list with cards
|
||||||
|
- Extract `ProjectCreateDialog` → create form in dialog
|
||||||
|
- Extract `ProjectEditDialog` → edit form in dialog
|
||||||
|
- Page becomes: data loader + layout + dialog state manager
|
||||||
|
|
||||||
|
#### 2. CSS Reorganization
|
||||||
|
|
||||||
|
**`styles.css` (5,683 lines → deleted)**
|
||||||
|
- Restore `styles/` directory with extracted files:
|
||||||
|
- `styles/tokens.css` — CSS custom properties (colors, spacing, typography)
|
||||||
|
- `styles/global.css` — global reset, body, shell layout
|
||||||
|
- `styles/utilities.css` — utility classes (.stack, .card, .muted, etc.)
|
||||||
|
- `styles/syntax-highlight.css` — code highlighting
|
||||||
|
- Restore `styles/pages/*.css` — page-specific styles:
|
||||||
|
- `styles/pages/dashboard.css`
|
||||||
|
- `styles/pages/projects.css`
|
||||||
|
- `styles/pages/sessions.css`
|
||||||
|
- `styles/pages/settings.css`
|
||||||
|
- `styles/pages/ssh-keys.css`
|
||||||
|
- `styles/pages/git-history.css`
|
||||||
|
- `styles/pages/repo-workspace.css`
|
||||||
|
- Restore component CSS modules:
|
||||||
|
- `components/features/terminal/TerminalComponent.module.css`
|
||||||
|
- `components/features/git/GitToolbar.module.css`
|
||||||
|
- `components/features/git/CommitDialog.module.css`
|
||||||
|
- `components/features/git/MergeDialog.module.css`
|
||||||
|
- `components/features/git/FileEditor.module.css`
|
||||||
|
- `components/features/git/FileBrowser.module.css`
|
||||||
|
- `components/features/git/FileViewer.module.css`
|
||||||
|
- `components/features/git/CommitPanel.module.css`
|
||||||
|
- `components/features/session/InstanceList.module.css`
|
||||||
|
- `components/features/settings/SettingsTabLayout.module.css`
|
||||||
|
- `components/layout/AppShell.module.css`
|
||||||
|
- Update all component imports to use `import styles from './ComponentName.module.css'`
|
||||||
|
- Update `main.tsx` to import `styles/tokens.css`, `styles/global.css`, `styles/utilities.css`, `styles/syntax-highlight.css`
|
||||||
|
- Update each page to import its `styles/pages/*.css`
|
||||||
|
- Delete monolithic `styles.css`
|
||||||
|
|
||||||
|
#### 3. Backend Router Slimming
|
||||||
|
|
||||||
|
**`api/tool/tool_instances.py` (2,900 → ~300 lines)**
|
||||||
|
- Extract terminal WebSocket handlers → `api/tool/terminal.py` (~400 lines)
|
||||||
|
- Extract instance lifecycle (create/start/stop/delete/restart) → `api/tool/tool_lifecycle.py` (~600 lines)
|
||||||
|
- Keep in `tool_instances.py`: CRUD endpoints (GET list, GET detail, POST, PATCH, DELETE) + instance proxy endpoint
|
||||||
|
|
||||||
|
**`api/project/git_repositories.py` (1,588 → ~300 lines)**
|
||||||
|
- Extract git command orchestration into `services/git/operations.py`
|
||||||
|
- Router keeps: auth, parameter validation, response building, error handling
|
||||||
|
- Service functions: `clone_repo`, `fetch_repo`, `pull_repo`, `push_repo`, `merge_repo`, etc.
|
||||||
|
|
||||||
|
**`api/config/config_profiles.py` (842 → ~200 lines)**
|
||||||
|
- Extract resolver orchestration into `services/config/resolver_service.py`
|
||||||
|
- Extract CRUD helpers into `services/config/crud_service.py`
|
||||||
|
- Router keeps: endpoint definitions, auth, input validation
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- Any new features or behavioral changes
|
||||||
|
- Database schema changes (no migrations)
|
||||||
|
- API contract changes (same endpoints, same request/response shapes)
|
||||||
|
- Frontend UI behavior changes (same components, same interactions)
|
||||||
|
- Moving existing `features/` components (already organized)
|
||||||
|
- Renaming files (naming already done)
|
||||||
|
- Changing any CSS rules (only moving them)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. All pages ≤ 150 lines (except `RepoWorkspacePage` which may stay at ~150)
|
||||||
|
2. All API routers ≤ 400 lines
|
||||||
|
3. No monolithic `styles.css` — all CSS in `styles/` directory or `.module.css` files
|
||||||
|
4. All existing tests pass without modification (behavior unchanged)
|
||||||
|
5. All existing API endpoints return identical responses
|
||||||
|
6. Frontend `npm run typecheck` passes
|
||||||
|
7. Frontend `npm run build` passes
|
||||||
|
8. Backend `py_compile` passes on all files
|
||||||
|
9. No import errors in browser console
|
||||||
|
10. File count increases (more files, smaller files)
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- `dev` branch is stable (all fixes from this session are committed)
|
||||||
|
- Backend compiles (`py_compile` pass)
|
||||||
|
- Frontend typechecks and builds (`tsc`, `vite build` pass)
|
||||||
|
- Current tests pass (or known failures are documented)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
## Phase 0: Preparation
|
||||||
|
|
||||||
|
- [ ] 0.1 Verify `dev` builds cleanly (backend `py_compile`, frontend `tsc` + `vite build`)
|
||||||
|
- [ ] 0.2 Document current file sizes for before/after comparison
|
||||||
|
- [ ] 0.3 Create component directory stubs if missing:
|
||||||
|
- `apps/web/src/components/features/tool-workshop/`
|
||||||
|
- `apps/web/src/components/features/config-profiles/`
|
||||||
|
- `apps/web/src/components/features/terminal/`
|
||||||
|
- `apps/web/src/components/features/settings/`
|
||||||
|
- `apps/web/src/components/features/ssh-keys/`
|
||||||
|
- `apps/api/src/services/git/operations.py` (extract from router)
|
||||||
|
- `apps/api/src/services/config/crud_service.py`
|
||||||
|
- `apps/api/src/services/config/resolver_service.py`
|
||||||
|
|
||||||
|
## Phase 1: Backend — Router Slimming
|
||||||
|
|
||||||
|
### 1.1 Terminal WebSocket Extraction
|
||||||
|
- [ ] 1.1.1 Create `api/tool/terminal.py` from terminal WebSocket handlers in `api/tool/tool_instances.py`
|
||||||
|
- [ ] 1.1.2 Move `_handle_terminal_websocket`, `_get_user_from_websocket`, `SessionRef` class
|
||||||
|
- [ ] 1.1.3 Update `main.py` to include `terminal_router` from `api.tool.terminal`
|
||||||
|
- [ ] 1.1.4 Remove terminal routes from `api/tool/tool_instances.py`
|
||||||
|
- [ ] 1.1.5 Verify `py_compile` passes
|
||||||
|
|
||||||
|
### 1.2 Instance Lifecycle Extraction
|
||||||
|
- [ ] 1.2.1 Create `api/tool/tool_lifecycle.py` for start/stop/restart/delete endpoints
|
||||||
|
- [ ] 1.2.2 Extract lifecycle endpoints from `api/tool/tool_instances.py`
|
||||||
|
- [ ] 1.2.3 Update `main.py` to include lifecycle router
|
||||||
|
- [ ] 1.2.4 Verify `py_compile` passes
|
||||||
|
|
||||||
|
### 1.3 Git Repository Router
|
||||||
|
- [ ] 1.3.1 Create `services/git/operations.py` for git command orchestration
|
||||||
|
- [ ] 1.3.2 Extract `clone_repo`, `fetch_repo`, `pull_repo`, `push_repo`, `merge_repo`, `commit_repo` helpers
|
||||||
|
- [ ] 1.3.3 Update `api/project/git_repositories.py` to call service functions
|
||||||
|
- [ ] 1.3.4 Verify `py_compile` passes
|
||||||
|
|
||||||
|
### 1.4 Config Profile Router
|
||||||
|
- [ ] 1.4.1 Extract CRUD helpers into `services/config/crud_service.py`
|
||||||
|
- [ ] 1.4.2 Extract resolver helpers into `services/config/resolver_service.py`
|
||||||
|
- [ ] 1.4.3 Update `api/config/config_profiles.py` to call services
|
||||||
|
- [ ] 1.4.4 Verify `py_compile` passes
|
||||||
|
|
||||||
|
## Phase 2: CSS Reorganization
|
||||||
|
|
||||||
|
### 2.1 Restore `styles/` Directory Structure
|
||||||
|
- [ ] 2.1.1 Create `styles/` directory
|
||||||
|
- [ ] 2.1.2 Extract `styles/tokens.css` from `styles.css` — CSS custom properties
|
||||||
|
- [ ] 2.1.3 Extract `styles/global.css` from `styles.css` — global reset, body, shell layout
|
||||||
|
- [ ] 2.1.4 Extract `styles/utilities.css` from `styles.css` — utility classes (.stack, .card, .muted, .dialog, etc.)
|
||||||
|
- [ ] 2.1.5 Extract `styles/syntax-highlight.css` from `styles.css` — code highlighting
|
||||||
|
- [ ] 2.1.6 Update `main.tsx` to import: `styles/tokens.css`, `styles/global.css`, `styles/utilities.css`, `styles/syntax-highlight.css`
|
||||||
|
- [ ] 2.1.7 Verify build passes
|
||||||
|
|
||||||
|
### 2.2 Restore Page-Specific CSS
|
||||||
|
- [ ] 2.2.1 Extract `styles/pages/dashboard.css` from `styles.css`
|
||||||
|
- [ ] 2.2.2 Extract `styles/pages/projects.css` from `styles.css`
|
||||||
|
- [ ] 2.2.3 Extract `styles/pages/sessions.css` from `styles.css`
|
||||||
|
- [ ] 2.2.4 Extract `styles/pages/settings.css` from `styles.css`
|
||||||
|
- [ ] 2.2.5 Extract `styles/pages/ssh-keys.css` from `styles.css`
|
||||||
|
- [ ] 2.2.6 Extract `styles/pages/git-history.css` from `styles.css`
|
||||||
|
- [ ] 2.2.7 Extract `styles/pages/repo-workspace.css` from `styles.css`
|
||||||
|
- [ ] 2.2.8 Update each page to import its page CSS
|
||||||
|
- [ ] 2.2.9 Verify build passes
|
||||||
|
|
||||||
|
### 2.3 Restore Component CSS Modules
|
||||||
|
- [ ] 2.3.1 Create `components/features/terminal/TerminalComponent.module.css` from terminal styles in `styles.css`
|
||||||
|
- [ ] 2.3.2 Create `components/features/git/GitToolbar.module.css` from git toolbar styles in `styles.css`
|
||||||
|
- [ ] 2.3.3 Create `components/features/git/CommitDialog.module.css` from commit dialog styles in `styles.css`
|
||||||
|
- [ ] 2.3.4 Create `components/features/git/MergeDialog.module.css` from merge dialog styles in `styles.css`
|
||||||
|
- [ ] 2.3.5 Create `components/features/git/FileEditor.module.css` from file editor styles in `styles.css`
|
||||||
|
- [ ] 2.3.6 Create `components/features/git/FileBrowser.module.css` from file browser styles in `styles.css`
|
||||||
|
- [ ] 2.3.7 Create `components/features/git/FileViewer.module.css` from file viewer styles in `styles.css`
|
||||||
|
- [ ] 2.3.8 Create `components/features/git/CommitPanel.module.css` from commit panel styles in `styles.css`
|
||||||
|
- [ ] 2.3.9 Create `components/features/session/InstanceList.module.css` from instance list styles in `styles.css`
|
||||||
|
- [ ] 2.3.10 Create `components/features/settings/SettingsTabLayout.module.css` from settings tab layout styles in `styles.css`
|
||||||
|
- [ ] 2.3.11 Create `components/layout/AppShell.module.css` from shell styles in `styles.css`
|
||||||
|
- [ ] 2.3.12 Update each component to use `import styles from './ComponentName.module.css'`
|
||||||
|
- [ ] 2.3.13 Remove extracted styles from `styles.css`
|
||||||
|
- [ ] 2.3.14 Verify build passes
|
||||||
|
|
||||||
|
### 2.4 Verify and Delete Monolith
|
||||||
|
- [ ] 2.4.1 Confirm `styles.css` is empty (or only has truly unclassifiable styles)
|
||||||
|
- [ ] 2.4.2 Delete `styles.css`
|
||||||
|
- [ ] 2.4.3 Verify build passes
|
||||||
|
- [ ] 2.4.4 Verify no visual regressions
|
||||||
|
|
||||||
|
## Phase 3: Frontend — Tool Workshop Page
|
||||||
|
|
||||||
|
- [ ] 3.1 Extract `ToolTypesTab` from `pages/ToolWorkshopPage.tsx` into `components/features/tool-workshop/ToolTypesTab.tsx`
|
||||||
|
- [ ] 3.2 Extract `ToolConfigsTab` into `components/features/tool-workshop/ToolConfigsTab.tsx`
|
||||||
|
- [ ] 3.3 Extract `ConfigFoldersTab` into `components/features/tool-workshop/ConfigFoldersTab.tsx`
|
||||||
|
- [ ] 3.4 Slim `pages/ToolWorkshopPage.tsx` to ~80 lines (tab switcher only)
|
||||||
|
- [ ] 3.5 Update imports in all consumers
|
||||||
|
- [ ] 3.6 Verify `tsc --noEmit` and `npm run build` pass
|
||||||
|
|
||||||
|
## Phase 4: Frontend — Config Profiles Page
|
||||||
|
|
||||||
|
- [ ] 4.1 Extract `ConfigProfileListView` into `components/features/config-profiles/ConfigProfileListView.tsx`
|
||||||
|
- [ ] 4.2 Extract `ConfigProfileDetailView` into `components/features/config-profiles/ConfigProfileDetailView.tsx`
|
||||||
|
- [ ] 4.3 Extract `ConfigProfileEditForm` into `components/features/config-profiles/ConfigProfileEditForm.tsx`
|
||||||
|
- [ ] 4.4 Extract `ConfigProfileMobileView` into `components/features/config-profiles/ConfigProfileMobileView.tsx`
|
||||||
|
- [ ] 4.5 Slim `pages/ConfigProfilesPage.tsx` to ~80 lines
|
||||||
|
- [ ] 4.6 Update imports
|
||||||
|
- [ ] 4.7 Verify `tsc --noEmit` and `npm run build` pass
|
||||||
|
|
||||||
|
## Phase 5: Frontend — Terminal Page
|
||||||
|
|
||||||
|
- [ ] 5.1 Extract `TerminalSessionManager` (tabs + auto-create) into `components/features/terminal/TerminalSessionManager.tsx`
|
||||||
|
- [ ] 5.2 Extract `MobileTerminalOverlay` into `components/features/terminal/MobileTerminalOverlay.tsx`
|
||||||
|
- [ ] 5.3 Extract fullscreen keyboard shortcut handler into `hooks/use-terminal-shortcuts.ts`
|
||||||
|
- [ ] 5.4 Slim `pages/TerminalPage.tsx` to ~80 lines
|
||||||
|
- [ ] 5.5 Update imports
|
||||||
|
- [ ] 5.6 Verify `tsc --noEmit` and `npm run build` pass
|
||||||
|
|
||||||
|
## Phase 6: Frontend — Settings & SSH Keys Pages
|
||||||
|
|
||||||
|
- [ ] 6.1 Extract `SettingsNavigation` into `components/features/settings/SettingsNavigation.tsx`
|
||||||
|
- [ ] 6.2 Slim `pages/SettingsPage.tsx` to ~80 lines
|
||||||
|
- [ ] 6.3 Extract `SSHKeyList` into `components/features/ssh-keys/SSHKeyList.tsx`
|
||||||
|
- [ ] 6.4 Extract `SSHKeyCreateForm` into `components/features/ssh-keys/SSHKeyCreateForm.tsx`
|
||||||
|
- [ ] 6.5 Slim `pages/SshKeysPage.tsx` to ~80 lines
|
||||||
|
- [ ] 6.6 Verify `tsc --noEmit` and `npm run build` pass
|
||||||
|
|
||||||
|
## Phase 7: Frontend — Projects & Repo Workspace Pages
|
||||||
|
|
||||||
|
- [ ] 7.1 Extract `ProjectList` into `components/features/project/ProjectList.tsx`
|
||||||
|
- [ ] 7.2 Extract `ProjectCreateDialog` into `components/features/project/ProjectCreateDialog.tsx`
|
||||||
|
- [ ] 7.3 Extract `ProjectEditDialog` into `components/features/project/ProjectEditDialog.tsx`
|
||||||
|
- [ ] 7.4 Slim `pages/ProjectsPage.tsx` to ~100 lines
|
||||||
|
- [ ] 7.5 Extract `WorkspaceLayout` into `components/features/workspace/WorkspaceLayout.tsx`
|
||||||
|
- [ ] 7.6 Slim `pages/RepoWorkspacePage.tsx` to ~150 lines
|
||||||
|
- [ ] 7.7 Verify `tsc --noEmit` and `npm run build` pass
|
||||||
|
|
||||||
|
## Phase 8: Integration and Verification
|
||||||
|
|
||||||
|
- [ ] 8.1 Run backend `py_compile` on all files
|
||||||
|
- [ ] 8.2 Run frontend `npm run typecheck`
|
||||||
|
- [ ] 8.3 Run frontend `npm run build`
|
||||||
|
- [ ] 8.4 Run frontend tests: `npm test`
|
||||||
|
- [ ] 8.5 Verify file size targets met (pages ≤ 150, routers ≤ 400, no `styles.css` monolith)
|
||||||
|
- [ ] 8.6 Verify no 404s or import errors in browser console
|
||||||
|
- [ ] 8.7 Manual smoke test: create project, start terminal, open config profiles
|
||||||
|
- [ ] 8.8 Verify visual regression: colors, spacing, typography unchanged
|
||||||
|
- [ ] 8.9 Verify mobile terminal styles intact
|
||||||
|
- [ ] 8.10 Verify notification dropdown styles intact
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-12
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# . (index)
|
||||||
|
dir: .
|
||||||
|
|
||||||
|
## Project Map Protocol
|
||||||
|
|
||||||
|
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||||
|
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||||
|
3. Load indexes before rich maps during task-start navigation.
|
||||||
|
4. Read the local rich map and actual source before editing.
|
||||||
|
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||||
|
6. If source and generated artifacts disagree, trust source.
|
||||||
|
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||||
|
8. After editing source, run `project_map_patch` for each changed file.
|
||||||
|
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||||
|
|
||||||
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
|
## role
|
||||||
|
Frontend architecture specification for real-time session progress tracking using Server-Sent Events and shared React state management.
|
||||||
|
## parent
|
||||||
|
-
|
||||||
|
## children
|
||||||
|
- specs
|
||||||
|
index: specs/.pi-map.index.md
|
||||||
|
map: specs/.pi-map.md
|
||||||
|
## files
|
||||||
|
- .openspec.yaml
|
||||||
|
- design.md
|
||||||
|
- proposal.md
|
||||||
|
- tasks.md
|
||||||
|
## links
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# .
|
||||||
|
dir: .
|
||||||
|
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
|
||||||
|
## Project Map Protocol
|
||||||
|
|
||||||
|
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||||
|
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||||
|
3. Load indexes before rich maps during task-start navigation.
|
||||||
|
4. Read the local rich map and actual source before editing.
|
||||||
|
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||||
|
6. If source and generated artifacts disagree, trust source.
|
||||||
|
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||||
|
8. After editing source, run `project_map_patch` for each changed file.
|
||||||
|
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||||
|
|
||||||
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
|
## role
|
||||||
|
Frontend architecture specification for real-time session progress tracking using Server-Sent Events and shared React state management.
|
||||||
|
## files
|
||||||
|
- .openspec.yaml | Defines an OpenAPI specification metadata file with schema type and creation date
|
||||||
|
- design.md | Design document for frontend-only refactoring of session lifecycle progress tracking and live UI updates using SSE events and shared React context. | dep: React, SSE (useEvents), SessionsContext, SessionOperationsContext, AppShell, DashboardPage, SessionsPage, CreateSessionForm, InstanceList, useInstanceActions, ToolStarter, StartToolFAB, EventToastBridge
|
||||||
|
- proposal.md | Proposes a frontend architecture change to add real-time session progress tracking via SSE and shared state across the application. | dep: React, SSE events, state management, AppShell, DashboardPage, SessionsPage, use-instance-actions, CreateSessionForm, SessionCard, InstanceCard
|
||||||
|
- tasks.md | A task checklist for refactoring a web application's session management to use shared state, a global progress panel, and removing legacy progress overlays. | dep: React, state/sessions.tsx, state/session-operations.tsx, useEvents, use-instance-actions.ts, AppShell, SessionsPage, DashboardPage, tool-starter.tsx, start-tool-fab.tsx, instance-list.tsx, create-session-form.tsx, session-card.tsx
|
||||||
|
## arch
|
||||||
|
Documentation-driven design package using OpenAPI metadata, markdown specifications (design/proposal/tasks), and event-driven UI patterns with SSE, shared React context, and global progress panel replacing legacy overlay components.
|
||||||
|
## tags
|
||||||
|
tsx, session, progress, react, state, design, shared, appshell
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Design: Tool Session Progress and Live Updates
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Provide structured, real-time progress feedback for every tool lifecycle action.
|
||||||
|
- Ensure all session lists (nav, dashboard, sessions page) update immediately after create/delete.
|
||||||
|
- Remove blocking and card-dimming overlays that hide context and provide no step detail.
|
||||||
|
- Keep the change frontend-only, reusing existing SSE and session APIs.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- No new backend endpoints or event types.
|
||||||
|
- No changes to the actual Docker/container orchestration logic.
|
||||||
|
- No redesign of the session card layout beyond action/progress affordances.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision: Global progress panel in the corner
|
||||||
|
A fixed panel (bottom-right desktop, bottom sheet style on mobile) lists in-flight operations. Each operation shows:
|
||||||
|
- Action icon + session display name
|
||||||
|
- Current step label derived from the latest SSE event
|
||||||
|
- A compact stepper: Created → Building → Starting → Probing → Ready/Error
|
||||||
|
- Dismiss button once settled
|
||||||
|
|
||||||
|
This is non-blocking, works across pages, and does not interfere with the modal create flow.
|
||||||
|
|
||||||
|
### Decision: SSE event-driven updates
|
||||||
|
The panel subscribes to `useEvents`. When an operation is started we record `instanceId` + `action`. Incoming events that match a tracked instance update the operation's message, status, and step. Events handled:
|
||||||
|
- `instance.created`, `instance.started`, `instance.restarted` → advance
|
||||||
|
- `instance.health_changed` with `status=running` → complete success
|
||||||
|
- `instance.error` → complete error
|
||||||
|
- `instance.stopped` → complete for stop action
|
||||||
|
- `instance.deleted` → complete for delete action
|
||||||
|
|
||||||
|
### Decision: Shared session state
|
||||||
|
`SessionsContext` is promoted from a nav-only data holder to the authoritative session list:
|
||||||
|
- Holds `sessions`, `isLoading`, `error`, `refreshSessions()`.
|
||||||
|
- Provides `addOrUpdateSession`, `removeSession` for optimistic updates.
|
||||||
|
- `AppShell`, `DashboardPage`, and `SessionsPage` read from this context instead of fetching independently.
|
||||||
|
|
||||||
|
### Decision: Optimistic create/delete updates
|
||||||
|
- **Create**: after the API returns a pending instance, add it to shared state and start tracking. Subsequent SSE events update its status.
|
||||||
|
- **Delete**: remove from shared state as soon as the API succeeds; the progress panel tracks the action until the `instance.deleted` event confirms it.
|
||||||
|
- **Other actions**: keep the existing per-action busy flag on the card for button disabled states, but the panel provides the detailed progress.
|
||||||
|
|
||||||
|
### Decision: Remove legacy overlays
|
||||||
|
- Delete `loading-overlay` and workflow step markup from `CreateSessionForm`.
|
||||||
|
- Remove `session-busy-overlay` and `instance-busy-overlay` (the dimming overlays), but keep button disabled states and small inline spinners.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**Risk: Shared context causes extra re-renders**
|
||||||
|
→ Mitigation: context value is memoized; lists use the same data they already fetched.
|
||||||
|
|
||||||
|
**Risk: SSE events arriving before operation is tracked**
|
||||||
|
→ Mitigation: start tracking before calling the create/start API; for deletes the removal is optimistic and the panel reconciles on the event.
|
||||||
|
|
||||||
|
**Risk: Duplicate feedback between panel and toasts**
|
||||||
|
→ Mitigation: panel shows in-flight steps; toasts remain for terminal success/error only. Existing `EventToastBridge` logic is left largely unchanged.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Extend `SessionsContext` with loading/error/refresh/update helpers.
|
||||||
|
2. Create `SessionOperationsContext` + `SessionProgressPanel` and render it in `AppShell`.
|
||||||
|
3. Update `useInstanceActions` to use shared state and start/stop tracking operations.
|
||||||
|
4. Update `ToolStarter`/`StartToolFAB` to add pending sessions and start tracking.
|
||||||
|
5. Update `CreateSessionForm` to remove overlay and report status to parent.
|
||||||
|
6. Update `InstanceList` to refresh shared state after create/delete.
|
||||||
|
7. Update `SessionsPage` and `DashboardPage` to consume shared context.
|
||||||
|
8. Remove legacy overlay styles.
|
||||||
|
9. Run typecheck, lint, and tests.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Tool Session Progress Indication and Live Updates
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Creating or deleting a tool session currently leaves the UI out of sync. After starting a tool from the floating action button, the new session does not appear on `/sessions` or the dashboard until the user manually refreshes or the 30-second poll fires. The existing progress feedback is also poor: `CreateSessionForm` shows a blocking overlay with only two static messages ("Creating instance…", "Starting container…"), and `SessionCard`/`InstanceCard` dim the whole card with a generic spinner. Users cannot see real backend progress (building, starting, probing, running, error) and receive no confirmation when an action finishes.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Introduce a **global session progress panel** that tracks all lifecycle actions (create, start, stop, restart, delete, recreate tunnel) using the existing SSE event stream (`instance.created`, `instance.started`, `instance.health_changed`, `instance.error`, `instance.stopped`, `instance.deleted`, `instance.restarted`).
|
||||||
|
- Make lists update **immediately** after create/delete by sharing session state across `AppShell`, `DashboardPage`, and `SessionsPage`.
|
||||||
|
- Remove the blocking full-screen overlay in `CreateSessionForm` and the card-level busy overlays; replace them with minimal disabled/spinner states and the global panel.
|
||||||
|
- Keep existing toast notifications for terminal states (success/error) while the panel handles in-flight progress.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `session-progress-panel`: Global, non-blocking progress UI for tool lifecycle actions driven by SSE events.
|
||||||
|
- `shared-session-state`: Centralized session list used by navigation, dashboard, and sessions page.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `session-lifecycle-ux`: Delete and stop actions now update shared state immediately; create/start actions add a pending session and show progress.
|
||||||
|
- `sessions-hub`: Dashboard and sessions lists reflect new/deleted sessions without manual refresh.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Frontend: new `state/session-operations.tsx`, new `components/features/session/session-progress-panel.tsx`, updates to `state/sessions.tsx`, `AppShell`, `SessionsPage`, `DashboardPage`, `hooks/use-instance-actions.ts`, `components/features/tool/instance-list.tsx`, `components/features/tool/tool-starter.tsx`, `components/features/session/create-session-form.tsx`, `components/features/session/session-card.tsx`, and styles.
|
||||||
|
- No API changes: relies on existing lifecycle events and `/users/me/sessions`.
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
- `npm run typecheck`
|
||||||
|
- `npm run lint`
|
||||||
|
- Existing frontend tests still pass
|
||||||
|
- Manual verification: create and delete sessions from the FAB and repository detail page; verify panel updates and lists refresh without manual reload.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user