Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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. -->
|
||||
|
||||
Last updated: 2026-06-02
|
||||
Last updated: 2026-06-05
|
||||
|
||||
## Sources scanned
|
||||
|
||||
- .opencode/skills
|
||||
- .claude/skills
|
||||
- /home/alex/.config/opencode/skills
|
||||
|
||||
## Contract
|
||||
|
||||
@@ -20,12 +19,11 @@ Last updated: 2026-06-02
|
||||
|
||||
| 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 | `/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 | `/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 | `/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 | `/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 | `/home/alex/projects/headquarter/.claude/skills/sift-backlog/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-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-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-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` |
|
||||
| `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` |
|
||||
|
||||
## Loading protocol
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""Config profile API endpoints."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
@@ -12,10 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.project import Project
|
||||
from src.models import ToolType
|
||||
from src.models import UserConfig
|
||||
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
||||
from src.schemas.config import (
|
||||
ConfigProfileCreate,
|
||||
ConfigProfileIncludeUpdate,
|
||||
@@ -27,125 +21,27 @@ from src.schemas.config import (
|
||||
)
|
||||
from src.services.config.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
check_include_cycle,
|
||||
resolve_profile,
|
||||
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__)
|
||||
|
||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||
|
||||
MAX_PROFILE_SIZE_MB = 10
|
||||
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
|
||||
def _calculate_profile_size(data: dict) -> int:
|
||||
"""Calculate approximate serialized size of profile data."""
|
||||
total = 0
|
||||
for key, value in data.get("env_vars", {}).items():
|
||||
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||
for key, value in data.get("runtime_hints", {}).items():
|
||||
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||
for mount in data.get("mounts", []):
|
||||
total += len(str(mount.get("target", "")).encode("utf-8"))
|
||||
total += len(str(mount.get("mode", "")).encode("utf-8"))
|
||||
for path, content in mount.get("files", {}).items():
|
||||
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||
for path, content in data.get("files", {}).items():
|
||||
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||
return total
|
||||
|
||||
|
||||
async def _get_profile_with_includes(
|
||||
session: AsyncSession, profile_id: uuid.UUID
|
||||
) -> ConfigProfile | None:
|
||||
"""Fetch a profile with includes eagerly loaded."""
|
||||
result = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.id == profile_id)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _check_access(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID | None = None,
|
||||
tool_type_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Verify user has access to referenced project and tool type."""
|
||||
if project_id is not None:
|
||||
project = await session.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||
)
|
||||
# Add ownership check if needed; for now just verify existence
|
||||
if tool_type_id is not None:
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
|
||||
)
|
||||
|
||||
|
||||
async def _validate_git_mounts(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
git_mounts: list[Any],
|
||||
project_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Validate git mount URLs.
|
||||
|
||||
Simply checks that remote_url looks like a valid git URL.
|
||||
Actual clone validation happens at instance startup time.
|
||||
"""
|
||||
for mount in git_mounts:
|
||||
remote_url = mount.get("remote_url")
|
||||
if not remote_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Git mount missing remote_url",
|
||||
)
|
||||
|
||||
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid git URL: {remote_url}",
|
||||
)
|
||||
|
||||
|
||||
def _profile_to_response(
|
||||
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
|
||||
) -> dict:
|
||||
return {
|
||||
"id": str(profile.id),
|
||||
"user_id": str(profile.user_id),
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"project_id": str(profile.project_id) if profile.project_id else None,
|
||||
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
|
||||
"env_vars": profile.env_vars or {},
|
||||
"runtime_hints": profile.runtime_hints or {},
|
||||
"mounts": profile.mounts or [],
|
||||
"git_mounts": profile.git_mounts or [],
|
||||
"files": profile.files or {},
|
||||
"is_default": profile.is_default,
|
||||
"includes": [
|
||||
{
|
||||
"id": str(inc.id),
|
||||
"included_profile_id": str(inc.included_profile_id),
|
||||
"order_index": inc.order_index,
|
||||
}
|
||||
for inc in (includes or profile.includes)
|
||||
],
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[ConfigProfileResponse])
|
||||
async def list_config_profiles(
|
||||
@@ -165,26 +61,21 @@ async def list_config_profiles(
|
||||
)
|
||||
|
||||
if project_id or tool_type_id:
|
||||
# Compatibility filter: include portable profiles and matching scoped profiles
|
||||
from sqlalchemy import or_
|
||||
|
||||
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
conditions: list = []
|
||||
# Portable profiles (no project, no tool)
|
||||
conditions.append(
|
||||
(ConfigProfile.project_id.is_(None))
|
||||
& (ConfigProfile.tool_type_id.is_(None))
|
||||
)
|
||||
if project_uuid:
|
||||
# Profiles matching this project (with or without tool)
|
||||
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||
if tool_uuid:
|
||||
# Profiles matching this tool (with or without project)
|
||||
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||
if project_uuid and tool_uuid:
|
||||
# Exact match
|
||||
conditions.append(
|
||||
(ConfigProfile.project_id == project_uuid)
|
||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||
@@ -194,7 +85,7 @@ async def list_config_profiles(
|
||||
|
||||
result = await session.execute(query)
|
||||
profiles = result.scalars().all()
|
||||
return [_profile_to_response(p) for p in profiles]
|
||||
return [profile_to_response(p) for p in profiles]
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -206,69 +97,9 @@ async def create_config_profile(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Create a new config profile."""
|
||||
user_uuid = current_user_id
|
||||
|
||||
# Check for duplicate name
|
||||
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)
|
||||
profile = await create_profile(session, current_user_id, data)
|
||||
logger.debug("Created config profile %s for user %s", profile.id, current_user_id)
|
||||
return profile_to_response(profile)
|
||||
|
||||
|
||||
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||
@@ -278,7 +109,7 @@ async def get_config_profile(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Get a config profile by ID."""
|
||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
@@ -287,7 +118,7 @@ async def get_config_profile(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
return _profile_to_response(profile)
|
||||
return profile_to_response(profile)
|
||||
|
||||
|
||||
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||
@@ -298,7 +129,7 @@ async def update_config_profile(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Update a config profile."""
|
||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
@@ -308,78 +139,9 @@ async def update_config_profile(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# 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()
|
||||
|
||||
profile = await update_profile(session, profile, data)
|
||||
logger.debug("Updated config profile %s", profile.id)
|
||||
return _profile_to_response(profile)
|
||||
return profile_to_response(profile)
|
||||
|
||||
|
||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -389,7 +151,7 @@ async def delete_config_profile(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Delete a config profile."""
|
||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
@@ -407,14 +169,14 @@ async def delete_config_profile(
|
||||
|
||||
|
||||
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||
async def update_profile_includes(
|
||||
async def update_profile_includes_endpoint(
|
||||
profile_id: str,
|
||||
data: ConfigProfileIncludeUpdate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Update the ordered includes for a config profile."""
|
||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
@@ -424,69 +186,8 @@ async def update_profile_includes(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
# Validate all included profiles exist and belong to the user
|
||||
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||
for inc_uuid in included_uuids:
|
||||
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||
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()
|
||||
profile = await update_includes(session, profile, included_uuids, current_user_id)
|
||||
|
||||
inc_result = await session.execute(
|
||||
select(ConfigProfileInclude).where(
|
||||
@@ -496,7 +197,7 @@ async def update_profile_includes(
|
||||
direct_includes = inc_result.scalars().all()
|
||||
|
||||
logger.debug("Updated includes for config profile %s", profile.id)
|
||||
return _profile_to_response(profile, list(direct_includes))
|
||||
return profile_to_response(profile, list(direct_includes))
|
||||
|
||||
|
||||
@router.get("/{profile_id}/preview")
|
||||
@@ -506,7 +207,7 @@ async def preview_config_profile(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Preview the resolved output of a config profile."""
|
||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
@@ -528,120 +229,19 @@ async def preview_config_profile(
|
||||
|
||||
|
||||
@router.get("/defaults/resolve")
|
||||
async def resolve_default_profile(
|
||||
async def resolve_default_profile_endpoint(
|
||||
project_id: str = Query(..., description="Project ID"),
|
||||
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Resolve the default config profile for a project/tool combination.
|
||||
|
||||
Selects by specificity:
|
||||
1. project+tool explicit default
|
||||
2. project explicit default
|
||||
3. tool explicit default
|
||||
4. global/user explicit default
|
||||
5. first created compatible profile
|
||||
6. none (returns null)
|
||||
"""
|
||||
user_uuid = current_user_id
|
||||
project_uuid = uuid.UUID(project_id)
|
||||
tool_uuid = uuid.UUID(tool_type_id)
|
||||
|
||||
# Fetch all compatible profiles ordered by created_at
|
||||
query = (
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.user_id == user_uuid)
|
||||
.where(
|
||||
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
||||
| (ConfigProfile.project_id == project_uuid)
|
||||
| (ConfigProfile.tool_type_id == tool_uuid)
|
||||
| (
|
||||
(ConfigProfile.project_id == project_uuid)
|
||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||
)
|
||||
)
|
||||
.order_by(ConfigProfile.created_at)
|
||||
"""Resolve the default config profile for a project/tool combination."""
|
||||
return await resolve_default_profile(
|
||||
session,
|
||||
current_user_id,
|
||||
uuid.UUID(project_id),
|
||||
uuid.UUID(tool_type_id),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
profiles = result.scalars().all()
|
||||
|
||||
if not profiles:
|
||||
return {"profile_id": None, "profile_name": None}
|
||||
|
||||
# Check explicit defaults by specificity
|
||||
explicit_defaults = [p for p in profiles if p.is_default]
|
||||
|
||||
# Most specific: project+tool
|
||||
for p in explicit_defaults:
|
||||
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
# Next: project only
|
||||
for p in explicit_defaults:
|
||||
if p.project_id == project_uuid and p.tool_type_id is None:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
# Next: tool only
|
||||
for p in explicit_defaults:
|
||||
if p.project_id is None and p.tool_type_id == tool_uuid:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
# Next: global/user (no project, no tool)
|
||||
for p in explicit_defaults:
|
||||
if p.project_id is None and p.tool_type_id is None:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
# Fall back to first created compatible profile
|
||||
first = profiles[0]
|
||||
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default profile management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_or_create_user_config(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> UserConfig:
|
||||
"""Get existing user config or create a new one."""
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
user_config = result.scalar_one_or_none()
|
||||
if user_config is None:
|
||||
user_config = UserConfig(user_id=user_id, config={})
|
||||
session.add(user_config)
|
||||
return user_config
|
||||
|
||||
|
||||
async def _validate_default_profiles(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
default_profiles: dict[str, str],
|
||||
) -> None:
|
||||
"""Validate that all profile IDs in default_profiles belong to the user."""
|
||||
for tool_type_id, profile_id_str in default_profiles.items():
|
||||
try:
|
||||
profile_uuid = uuid.UUID(profile_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
|
||||
)
|
||||
profile = await session.get(ConfigProfile, profile_uuid)
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Profile not found: {profile_id_str}",
|
||||
)
|
||||
if profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Profile does not belong to user: {profile_id_str}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/defaults")
|
||||
@@ -664,8 +264,8 @@ async def set_default_profiles_endpoint(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Set default profile mappings for the current user."""
|
||||
await _validate_default_profiles(session, user_id, data.default_profiles)
|
||||
user_config = await _get_or_create_user_config(session, user_id)
|
||||
await validate_default_profiles(session, user_id, data.default_profiles)
|
||||
user_config = await get_or_create_user_config(session, user_id)
|
||||
user_config.config = {
|
||||
**user_config.config,
|
||||
"default_profiles": data.default_profiles,
|
||||
@@ -691,152 +291,10 @@ async def get_default_profile_for_tool_type_endpoint(
|
||||
|
||||
|
||||
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||
async def validate_git_url(
|
||||
async def validate_git_url_endpoint(
|
||||
data: ValidateGitUrlRequest,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ValidateGitUrlResponse:
|
||||
"""Validate a git remote URL and list available branches.
|
||||
|
||||
Parses the URL, suggests corrections for browser URLs, and runs
|
||||
git ls-remote to verify reachability and enumerate branches.
|
||||
"""
|
||||
parse_result = parse_git_url(data.url)
|
||||
original_url = data.url.strip()
|
||||
url_to_check = parse_result.get("base_url") or original_url
|
||||
|
||||
if not url_to_check:
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error=parse_result.get("message", "Invalid URL"),
|
||||
error_code=parse_result.get("error_code", "INVALID_URL"),
|
||||
)
|
||||
|
||||
# If the URL needed parsing, return suggestion without checking remote
|
||||
if parse_result.get("needs_parsing") and url_to_check != original_url:
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
suggested_url=url_to_check,
|
||||
error=parse_result.get("message"),
|
||||
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
|
||||
)
|
||||
|
||||
# Optional SSH key for private repos
|
||||
env = None
|
||||
key_path = None
|
||||
if data.ssh_key_id:
|
||||
from src.models import SSHKey
|
||||
from src.services.shared.ssh_keys import _get_fernet
|
||||
|
||||
try:
|
||||
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error="Invalid SSH key ID format",
|
||||
error_code="INVALID_SSH_KEY",
|
||||
)
|
||||
|
||||
ssh_key = await session.get(SSHKey, ssh_key_uuid)
|
||||
if ssh_key is None or ssh_key.user_id != current_user_id:
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error="SSH key not found or not authorized",
|
||||
error_code="SSH_KEY_NOT_FOUND",
|
||||
)
|
||||
|
||||
import tempfile
|
||||
|
||||
fernet = _get_fernet()
|
||||
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||
try:
|
||||
os.write(fd, private_key.encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.chmod(key_path, 0o600)
|
||||
env = {
|
||||
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "ls-remote", "--heads", url_to_check],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error="Remote repository check timed out",
|
||||
error_code="TIMEOUT",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error="git command not found on server",
|
||||
error_code="GIT_NOT_FOUND",
|
||||
)
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
if (
|
||||
"could not resolve" in stderr.lower()
|
||||
or "unable to access" in stderr.lower()
|
||||
):
|
||||
error_msg = "Could not reach repository. Check the URL and network access."
|
||||
error_code = "UNREACHABLE"
|
||||
elif (
|
||||
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
|
||||
):
|
||||
error_msg = (
|
||||
"Authentication failed. Provide an SSH key for private repositories."
|
||||
)
|
||||
error_code = "AUTH_FAILED"
|
||||
else:
|
||||
error_msg = f"Repository not accessible: {stderr[:200]}"
|
||||
error_code = "REMOTE_ERROR"
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error=error_msg,
|
||||
error_code=error_code,
|
||||
)
|
||||
|
||||
# Parse branches from ls-remote output
|
||||
branches: list[str] = []
|
||||
default_branch = "main"
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) == 2:
|
||||
ref = parts[1]
|
||||
# refs/heads/branch-name
|
||||
if ref.startswith("refs/heads/"):
|
||||
branch_name = ref[len("refs/heads/") :]
|
||||
branches.append(branch_name)
|
||||
if branch_name in ("main", "master"):
|
||||
default_branch = branch_name
|
||||
|
||||
if not branches:
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error="No branches found in remote repository",
|
||||
error_code="NO_BRANCHES",
|
||||
)
|
||||
|
||||
return ValidateGitUrlResponse(
|
||||
valid=True,
|
||||
suggested_url=url_to_check if url_to_check != original_url else None,
|
||||
branches=branches,
|
||||
default_branch=default_branch,
|
||||
)
|
||||
"""Validate a git remote URL and list available branches."""
|
||||
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -45,3 +45,91 @@ class GitRepositoryResponse(BaseModel):
|
||||
|
||||
class UpdateSSHKeyRequest(BaseModel):
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
) -> None:
|
||||
"""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:
|
||||
container_info = get_container_status(instance.container_id or "")
|
||||
container_info = get_container_status(instance.container_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Health check failed for instance %s",
|
||||
@@ -135,7 +143,11 @@ class HealthMonitor:
|
||||
previous = self._last_known_state.get(instance.id)
|
||||
|
||||
# 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 previous is None or not self._snapshots_equal(previous, snapshot):
|
||||
@@ -144,10 +156,34 @@ class HealthMonitor:
|
||||
)
|
||||
self._last_known_state[instance.id] = snapshot
|
||||
|
||||
def _derive_status(self, snapshot: HealthSnapshot) -> str:
|
||||
"""Derive instance status from health 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":
|
||||
# If the container was never seen running, assume it's still
|
||||
# starting or was deleted intentionally; don't flag as error.
|
||||
if previous is None and current_status == "starting":
|
||||
return "starting"
|
||||
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 == "exited":
|
||||
return "error"
|
||||
|
||||
if snapshot.container_status != "running":
|
||||
return "error"
|
||||
|
||||
if snapshot.tunnel_healthy is False:
|
||||
return "unhealthy"
|
||||
return "running"
|
||||
@@ -223,6 +259,16 @@ class HealthMonitor:
|
||||
# Create notification for instance owner (fire-and-forget)
|
||||
# Only send warnings and errors; skip "recovered" info notifications.
|
||||
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"
|
||||
severity = "error"
|
||||
title = "Container failed"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 { Icon } from "../../icon";
|
||||
import { validateGitUrl } from "../../../api/config_profiles";
|
||||
import type { GitMount, GitMountMapping } from "../../../api/config_profiles";
|
||||
import { validateGitUrl } from "../../../api/config-profiles";
|
||||
import type { GitMount, GitMountMapping } from "../../../api/config-profiles";
|
||||
|
||||
interface GitMountEditorProps {
|
||||
mounts: GitMount[];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import { EventToastBridge } from "./event-toast-bridge";
|
||||
import { useEventContext } from "../state/events";
|
||||
import { useEventContext } from "../../../state/events";
|
||||
import { getUserConfig } from "../../../api/settings";
|
||||
import { handleEventToast } from "./toast-rules";
|
||||
import { handleEventToast } from "../../toast-rules";
|
||||
import type { InstanceEventPayload } from "../../../types/events";
|
||||
|
||||
vi.mock("../state/events", () => ({
|
||||
vi.mock("../../../state/events", () => ({
|
||||
useEventContext: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -14,8 +14,8 @@ vi.mock("../../../api/settings", () => ({
|
||||
getUserConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./toast-rules", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./toast-rules")>();
|
||||
vi.mock("../../toast-rules", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../toast-rules")>();
|
||||
return {
|
||||
...actual,
|
||||
handleEventToast: vi.fn(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { NotificationCenter } from "./notification-center";
|
||||
import { NotificationProvider } from "../state/notifications";
|
||||
import { NotificationProvider } from "../../../state/notifications";
|
||||
|
||||
vi.mock("../../../api/notifications", () => ({
|
||||
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 { useMobileViewport } from "../../../hooks/use-mobile-viewport";
|
||||
import { NotificationItem } from "./notification-item";
|
||||
import { Icon } from "../../icon";
|
||||
|
||||
@@ -22,15 +24,39 @@ export function NotificationCenter({
|
||||
setIsDropdownOpen,
|
||||
} = useNotifications();
|
||||
|
||||
const isMobile = useMobileViewport();
|
||||
const bellRef = useRef<HTMLButtonElement>(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(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
updatePosition();
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
!dropdownRef.current.contains(target) &&
|
||||
!bellRef.current?.contains(target)
|
||||
) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
@@ -42,14 +68,20 @@ export function NotificationCenter({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [isDropdownOpen, setIsDropdownOpen]);
|
||||
}, [isDropdownOpen, setIsDropdownOpen, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDropdownOpen) {
|
||||
@@ -66,6 +98,7 @@ export function NotificationCenter({
|
||||
return (
|
||||
<div className="notification-center">
|
||||
<button
|
||||
ref={bellRef}
|
||||
type="button"
|
||||
className="notification-bell"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
@@ -79,56 +112,68 @@ export function NotificationCenter({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
role="dialog"
|
||||
aria-label="Notifications"
|
||||
className="notification-dropdown"
|
||||
>
|
||||
<div className="notification-dropdown-header">
|
||||
<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}
|
||||
/>
|
||||
))
|
||||
{isDropdownOpen &&
|
||||
createPortal(
|
||||
<>
|
||||
{isMobile && (
|
||||
<div
|
||||
className="notification-dropdown-backdrop"
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="notification-dropdown-footer">
|
||||
<button
|
||||
type="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>
|
||||
<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>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<div className="notification-dropdown-footer">
|
||||
<button
|
||||
type="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>
|
||||
)}
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../icon";
|
||||
import { formatRelativeTime } from "../../../utils/time";
|
||||
import type { NotificationItem as NotificationItemType } from "../../../api/notifications";
|
||||
@@ -17,6 +18,18 @@ const severityIconMap: Record<string, IconName> = {
|
||||
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({
|
||||
notification,
|
||||
onMarkRead,
|
||||
@@ -24,6 +37,11 @@ export function NotificationItem({
|
||||
}: NotificationItemProps) {
|
||||
const isUnread = notification.read_at === null;
|
||||
const iconName = severityIconMap[notification.severity] ?? "info";
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const hasDetails =
|
||||
!!notification.message ||
|
||||
Object.keys(notification.metadata || {}).length > 0;
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -35,11 +53,36 @@ export function NotificationItem({
|
||||
</div>
|
||||
<div className="notification-item-content">
|
||||
<div className="notification-item-title">{notification.title}</div>
|
||||
{notification.message && (
|
||||
<div className="notification-item-message">
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="notification-item-time">
|
||||
{formatRelativeTime(notification.created_at)}
|
||||
</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 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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Icon } from "../../icon";
|
||||
import { WorkspaceCreateForm } from "../workspace/workspace-create-form";
|
||||
import type { ProjectWithRepos, WorkspaceSummary } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
project: ProjectWithRepos;
|
||||
expanded: boolean;
|
||||
deleteConfirm: boolean;
|
||||
workspaceLoading: string | null;
|
||||
showCreateForm: string | null;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onCreateWorkspace: (repoId: string) => void;
|
||||
onWorkspaceAction: (
|
||||
repoId: string,
|
||||
workspace: WorkspaceSummary,
|
||||
action: "sync" | "delete",
|
||||
) => void;
|
||||
onCancelCreate: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export const ProjectCard = ({
|
||||
project,
|
||||
expanded,
|
||||
deleteConfirm,
|
||||
workspaceLoading,
|
||||
showCreateForm,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onCreateWorkspace,
|
||||
onWorkspaceAction,
|
||||
onCancelCreate,
|
||||
onCreated,
|
||||
}: Props) => {
|
||||
return (
|
||||
<article className="card project-card">
|
||||
<div className="project-info-row">
|
||||
<button
|
||||
className="project-toggle"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
|
||||
<div>
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="muted project-description">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{project.repositories?.length > 0 && (
|
||||
<span className="repo-count">
|
||||
{project.repositories.length} repo
|
||||
{project.repositories.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="project-actions">
|
||||
<button className="ghost-button" onClick={onEdit} type="button">
|
||||
<Icon name="edit" size="sm" /> Edit
|
||||
</button>
|
||||
{deleteConfirm ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={onConfirmDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={onCancelDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="project-detail">
|
||||
{(project.repositories || []).length === 0 ? (
|
||||
<p className="muted">No repositories yet.</p>
|
||||
) : (
|
||||
<div className="repo-list">
|
||||
{(project.repositories || []).map((repo) => (
|
||||
<div key={repo.id} className="repo-block">
|
||||
<div className="repo-header">
|
||||
<h4>{repo.name}</h4>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => onCreateWorkspace(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
</div>
|
||||
{showCreateForm === repo.id && (
|
||||
<WorkspaceCreateForm
|
||||
defaultProjectId={project.id}
|
||||
defaultRepoId={repo.id}
|
||||
onSubmit={onCreated}
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
)}
|
||||
{repo.workspaces.length === 0 ? (
|
||||
<p className="muted">No workspaces.</p>
|
||||
) : (
|
||||
<div className="workspace-grid">
|
||||
{repo.workspaces.map((ws) => (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`workspace-chip ${ws.status}`}
|
||||
>
|
||||
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
|
||||
<span className="ws-branch">
|
||||
<Icon name="branch" size="sm" /> {ws.branch}
|
||||
</span>
|
||||
{ws.instance_count > 0 && (
|
||||
<span className="ws-instances">
|
||||
{ws.instance_count} tool
|
||||
{ws.instance_count > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<div className="ws-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={workspaceLoading === ws.id}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(repo.id, ws, "sync")
|
||||
}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-text"
|
||||
disabled={workspaceLoading === ws.id}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(repo.id, ws, "delete")
|
||||
}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Icon } from "../../icon";
|
||||
|
||||
interface Props {
|
||||
mode: "create" | "edit";
|
||||
name: string;
|
||||
description: string;
|
||||
error: string | null;
|
||||
onNameChange: (name: string) => void;
|
||||
onDescriptionChange: (desc: string) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ProjectDialog = ({
|
||||
mode,
|
||||
name,
|
||||
description,
|
||||
error,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>{mode === "create" ? "Create Project" : "Edit Project"}</h2>
|
||||
<form onSubmit={onSubmit} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Description
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={onCancel} type="button">
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
{mode === "create" ? (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,343 +1,377 @@
|
||||
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 { Icon } from "../../icon";
|
||||
|
||||
type CreateMode = "clone" | "blank";
|
||||
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
|
||||
type UrlValidationStatus =
|
||||
| "idle"
|
||||
| "validating"
|
||||
| "valid"
|
||||
| "needs-parsing"
|
||||
| "invalid";
|
||||
|
||||
interface RepositoryCreateDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void> | void;
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||
const [formName, setFormName] = useState("");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repoName, setRepoName] = useState("");
|
||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||
const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
|
||||
const [formError, setFormError] = useState<string | 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);
|
||||
export const RepositoryCreateDialog = ({
|
||||
projectId,
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: RepositoryCreateDialogProps) => {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||
const [formName, setFormName] = useState("");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repoName, setRepoName] = useState("");
|
||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
|
||||
const [formError, setFormError] = useState<string | 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(() => {
|
||||
if (!open && debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open && debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const data = await listSSHKeys();
|
||||
setSshKeys(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadKeys();
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const data = await listSSHKeys();
|
||||
setSshKeys(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadKeys();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!useAdvancedUrl) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!useAdvancedUrl) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
if (!advancedUrl.trim()) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
if (!advancedUrl.trim()) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlValidation({ status: "validating", result: null });
|
||||
setUrlValidation({ status: "validating", result: null });
|
||||
|
||||
debounceTimer.current = setTimeout(async () => {
|
||||
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);
|
||||
debounceTimer.current = setTimeout(async () => {
|
||||
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 () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [advancedUrl, open, useAdvancedUrl]);
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [advancedUrl, open, useAdvancedUrl]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCreateMode("clone");
|
||||
setFormName("");
|
||||
setOwner("");
|
||||
setRepoName("");
|
||||
setAdvancedUrl("");
|
||||
setUseAdvancedUrl(true);
|
||||
setFormError(null);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setSelectedSshKey("");
|
||||
};
|
||||
const resetForm = () => {
|
||||
setCreateMode("clone");
|
||||
setFormName("");
|
||||
setOwner("");
|
||||
setRepoName("");
|
||||
setAdvancedUrl("");
|
||||
setUseAdvancedUrl(true);
|
||||
setFormError(null);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setSelectedSshKey("");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim()) {
|
||||
setFormError("Repository name is required");
|
||||
return;
|
||||
}
|
||||
if (!formName.trim()) {
|
||||
setFormError("Repository name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const input: GitRepositoryCreate = {
|
||||
name: formName.trim(),
|
||||
remote_url: undefined,
|
||||
};
|
||||
try {
|
||||
const input: GitRepositoryCreate = {
|
||||
name: formName.trim(),
|
||||
remote_url: undefined,
|
||||
};
|
||||
|
||||
if (createMode === "clone") {
|
||||
if (useAdvancedUrl) {
|
||||
if (!advancedUrl.trim()) {
|
||||
setFormError("Remote URL is required for advanced cloning");
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (createMode === "clone") {
|
||||
if (useAdvancedUrl) {
|
||||
if (!advancedUrl.trim()) {
|
||||
setFormError("Remote URL is required for advanced cloning");
|
||||
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);
|
||||
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");
|
||||
}
|
||||
};
|
||||
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",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseSuggestedUrl = () => {
|
||||
if (urlValidation.result?.base_url) {
|
||||
setAdvancedUrl(urlValidation.result.base_url);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setFormError(null);
|
||||
}
|
||||
};
|
||||
const handleUseSuggestedUrl = () => {
|
||||
if (urlValidation.result?.base_url) {
|
||||
setAdvancedUrl(urlValidation.result.base_url);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setFormError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getUrlInputClass = () => {
|
||||
switch (urlValidation.status) {
|
||||
case "valid":
|
||||
return "valid-url";
|
||||
case "needs-parsing":
|
||||
return "needs-parsing-url";
|
||||
case "invalid":
|
||||
return "invalid-url";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const getUrlInputClass = () => {
|
||||
switch (urlValidation.status) {
|
||||
case "valid":
|
||||
return "valid-url";
|
||||
case "needs-parsing":
|
||||
return "needs-parsing-url";
|
||||
case "invalid":
|
||||
return "invalid-url";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted">
|
||||
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">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "clone"}
|
||||
onChange={() => setCreateMode("clone")}
|
||||
/>
|
||||
Clone existing repository
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "blank"}
|
||||
onChange={() => setCreateMode("blank")}
|
||||
/>
|
||||
Create blank repository
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Repository name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="repository-name"
|
||||
/>
|
||||
</label>
|
||||
{createMode === "clone" && !useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Owner
|
||||
<input
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(event) => setOwner(event.target.value)}
|
||||
placeholder="owner"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(event) => setRepoName(event.target.value)}
|
||||
placeholder="repo-name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
SSH Key
|
||||
<select
|
||||
value={selectedSshKey}
|
||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||
>
|
||||
<option value="">Select SSH key (optional)...</option>
|
||||
{sshKeys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(true)}
|
||||
>
|
||||
Use full URL instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{createMode === "clone" && useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Remote URL
|
||||
<input
|
||||
type="text"
|
||||
value={advancedUrl}
|
||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={getUrlInputClass()}
|
||||
/>
|
||||
{urlValidation.status === "validating" && (
|
||||
<span className="validation-status validating">Validating...</span>
|
||||
)}
|
||||
{urlValidation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
<Icon name="success" size="sm" /> Valid git URL
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
||||
<div className="url-suggestion">
|
||||
<span className="validation-status warning">
|
||||
<Icon name="warning" size="sm" /> This looks like a browser URL
|
||||
</span>
|
||||
<div className="suggestion-actions">
|
||||
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleUseSuggestedUrl}
|
||||
>
|
||||
Use Suggested
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{urlValidation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
<Icon name="error" size="sm" /> Invalid URL
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="form-field">
|
||||
SSH Key
|
||||
<select
|
||||
value={selectedSshKey}
|
||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||
>
|
||||
<option value="">Select SSH key (optional)...</option>
|
||||
{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>
|
||||
);
|
||||
return (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted">
|
||||
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">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "clone"}
|
||||
onChange={() => setCreateMode("clone")}
|
||||
/>
|
||||
Clone existing repository
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "blank"}
|
||||
onChange={() => setCreateMode("blank")}
|
||||
/>
|
||||
Create blank repository
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Repository name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="repository-name"
|
||||
/>
|
||||
</label>
|
||||
{createMode === "clone" && !useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Owner
|
||||
<input
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(event) => setOwner(event.target.value)}
|
||||
placeholder="owner"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(event) => setRepoName(event.target.value)}
|
||||
placeholder="repo-name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
SSH Key
|
||||
<select
|
||||
value={selectedSshKey}
|
||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||
>
|
||||
<option value="">Select SSH key (optional)...</option>
|
||||
{sshKeys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="muted">
|
||||
SSH target: git@git.commumedia.org:{owner || "owner"}/
|
||||
{repoName || "repo"}.git
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(true)}
|
||||
>
|
||||
Use full URL instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{createMode === "clone" && useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Remote URL
|
||||
<input
|
||||
type="text"
|
||||
value={advancedUrl}
|
||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={getUrlInputClass()}
|
||||
/>
|
||||
{urlValidation.status === "validating" && (
|
||||
<span className="validation-status validating">
|
||||
Validating...
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
<Icon name="success" size="sm" /> Valid git URL
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "needs-parsing" &&
|
||||
urlValidation.result && (
|
||||
<div className="url-suggestion">
|
||||
<span className="validation-status warning">
|
||||
<Icon name="warning" size="sm" /> This looks like a
|
||||
browser URL
|
||||
</span>
|
||||
<div className="suggestion-actions">
|
||||
<span className="suggested-url">
|
||||
Suggested: {urlValidation.result.base_url}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleUseSuggestedUrl}
|
||||
>
|
||||
Use Suggested
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{urlValidation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
<Icon name="error" size="sm" /> Invalid URL
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="form-field">
|
||||
SSH Key
|
||||
<select
|
||||
value={selectedSshKey}
|
||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||
>
|
||||
<option value="">Select SSH key (optional)...</option>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Project } from "../../../types";
|
||||
import { listRepositoryBranches, type GitRepository, type Branch } from "../../../api/git-repositories";
|
||||
import type { ToolType } from "../../../api/tool-types";
|
||||
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
||||
import { listConfigProfiles, type ConfigProfile } from "../../../api/config_profiles";
|
||||
import { listConfigProfiles, type ConfigProfile } from "../../../api/config-profiles";
|
||||
|
||||
interface CreateSessionFormProps {
|
||||
projects: Project[];
|
||||
|
||||
@@ -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 { FitAddon } from "xterm-addon-fit";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import { WebglAddon } from "xterm-addon-webgl";
|
||||
import "xterm/css/xterm.css";
|
||||
|
||||
import {
|
||||
@@ -310,25 +309,13 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
// Load WebGL renderer for GPU acceleration, fall back to DOM
|
||||
let webglAddon: WebglAddon | null = null;
|
||||
try {
|
||||
webglAddon = new WebglAddon();
|
||||
term.loadAddon(webglAddon);
|
||||
webglAddon.onContextLoss(() => {
|
||||
console.warn("WebGL context lost, falling back to DOM renderer");
|
||||
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);
|
||||
}
|
||||
// NOTE: WebGL renderer disabled.
|
||||
// The WebGL addon causes black-on-black rendering artifacts with
|
||||
// tmux/vim reverse-video (inverse color) sequences on desktop.
|
||||
// Mobile already uses the DOM renderer (WebGL fails there), which
|
||||
// handles these color attributes correctly. The DOM renderer is
|
||||
// fast enough for typical terminal workloads.
|
||||
// See: xterm.js WebGL known issues with reverse video / minimumContrastRatio
|
||||
|
||||
const container = terminalRef.current;
|
||||
|
||||
@@ -585,16 +572,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
window.clearInterval(heartbeatCheckRef.current);
|
||||
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 {
|
||||
term.dispose();
|
||||
} 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,7 +11,7 @@ import {
|
||||
} from "../../../api/sessions";
|
||||
import type { ToolType } from "../../../api/tool-types";
|
||||
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 { useEventContext } from "../../../state/events";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { extractErrorMessage } from "../../../utils/errors";
|
||||
import {
|
||||
compileToolDefinition,
|
||||
type ToolDefinitionManifest,
|
||||
} from "../../../api/tool_definitions";
|
||||
} from "../../../api/tool-definitions";
|
||||
|
||||
interface PackageEntry {
|
||||
name: string;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Icon } from "../../icon";
|
||||
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 type { Workspace } from "../../../types/workspace";
|
||||
import type { ToolInstance } from "../../../api/sessions";
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { apiClient } from "../../../api/client";
|
||||
import { EmptyState } from "../../data-states";
|
||||
import { Icon } from "../../icon";
|
||||
import type { GitStatus } from "../../../api/git-repositories";
|
||||
|
||||
interface FileTreeEntry {
|
||||
name: string;
|
||||
type: "file" | "directory";
|
||||
path: string;
|
||||
size?: number;
|
||||
mode?: string;
|
||||
last_commit?: {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
gitStatus: GitStatus | null;
|
||||
}
|
||||
|
||||
export const FileBrowser = ({ projectId, repoId, gitStatus }: Props) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const branch = searchParams.get("branch") || "main";
|
||||
const path = searchParams.get("path") || "";
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/files`,
|
||||
{ params: { branch, path } }
|
||||
);
|
||||
setEntries(response.data.entries || []);
|
||||
} catch {
|
||||
setError("Failed to load files");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId, branch, path]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => void loadFiles();
|
||||
window.addEventListener("refresh-file-tree", handleRefresh);
|
||||
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleEntryClick = (entry: FileTreeEntry) => {
|
||||
if (entry.type === "directory") {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("path", entry.path);
|
||||
setSearchParams(newParams);
|
||||
} else {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("file", entry.path);
|
||||
setSearchParams(newParams);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateUp = () => {
|
||||
if (!path) return;
|
||||
const parentPath = path.split("/").slice(0, -1).join("/");
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (parentPath) {
|
||||
newParams.set("path", parentPath);
|
||||
} else {
|
||||
newParams.delete("path");
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const getFileStatus = (filePath: string): string | null => {
|
||||
if (!gitStatus) return null;
|
||||
if (gitStatus.modified.includes(filePath)) return "modified";
|
||||
if (gitStatus.added.includes(filePath)) return "added";
|
||||
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
||||
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading files...</p>;
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{path && (
|
||||
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
|
||||
<Icon name="folder" size="sm" /> ..
|
||||
</button>
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<EmptyState message="No files in this repository yet." />
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||
return (
|
||||
<button
|
||||
key={entry.path}
|
||||
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
|
||||
{fileStatus && (
|
||||
<span className={`file-status-indicator ${fileStatus}`}>
|
||||
{fileStatus === "modified" && "M"}
|
||||
{fileStatus === "added" && "A"}
|
||||
{fileStatus === "deleted" && "D"}
|
||||
{fileStatus === "untracked" && "?"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Icon } from "../../icon";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { FileEditor } from "../git/file-editor";
|
||||
import { CommitPanel } from "../git/commit-panel";
|
||||
import { GitToolbar } from "../git/git-toolbar";
|
||||
import { InstanceList } from "../tool/instance-list";
|
||||
import type { GitRepository, GitStatus } from "../../../api/git-repositories";
|
||||
import type { ToolType } from "../../../api/tool-types";
|
||||
import type { Project } from "../../../hooks/use-repo-workspace";
|
||||
|
||||
type MobileTab = "files" | "editor" | "git" | "terminal";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
project: Project | null;
|
||||
isMobile: boolean;
|
||||
mobileTab: MobileTab;
|
||||
selectedRepoId: string | null;
|
||||
selectedRepo: GitRepository | undefined;
|
||||
branches: string[];
|
||||
currentBranch: string;
|
||||
gitStatus: GitStatus | null;
|
||||
toolTypes: ToolType[];
|
||||
repositories: GitRepository[];
|
||||
onMobileTabChange: (tab: MobileTab) => void;
|
||||
onRepoChange: (repoId: string) => void;
|
||||
onBranchChange: (branch: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const WorkspaceLayout = ({
|
||||
projectId,
|
||||
project,
|
||||
isMobile,
|
||||
mobileTab,
|
||||
selectedRepoId,
|
||||
selectedRepo,
|
||||
branches,
|
||||
currentBranch,
|
||||
gitStatus,
|
||||
toolTypes,
|
||||
repositories,
|
||||
onMobileTabChange,
|
||||
onRepoChange,
|
||||
onBranchChange,
|
||||
onRefresh,
|
||||
}: Props) => {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="mobile-workspace">
|
||||
<div className="mobile-workspace-header">
|
||||
<select
|
||||
value={selectedRepoId || ""}
|
||||
onChange={(e) => onRepoChange(e.target.value)}
|
||||
className="mobile-repo-selector"
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedRepoId && (
|
||||
<select
|
||||
value={currentBranch}
|
||||
onChange={(e) => onBranchChange(e.target.value)}
|
||||
className="mobile-branch-selector"
|
||||
>
|
||||
{branches.map((branch) => (
|
||||
<option key={branch} value={branch}>
|
||||
{branch}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mobile-workspace-content">
|
||||
{mobileTab === "files" && selectedRepoId && (
|
||||
<FileBrowser projectId={projectId} repoId={selectedRepoId} gitStatus={gitStatus} />
|
||||
)}
|
||||
{mobileTab === "editor" && selectedRepoId && (
|
||||
<FileEditor projectId={projectId} repoId={selectedRepoId} />
|
||||
)}
|
||||
{mobileTab === "git" && selectedRepoId && gitStatus && (
|
||||
<div className="mobile-git-view">
|
||||
<CommitPanel
|
||||
projectId={projectId}
|
||||
repoId={selectedRepoId}
|
||||
modified={gitStatus.modified}
|
||||
added={gitStatus.added}
|
||||
deleted={gitStatus.deleted}
|
||||
untracked={gitStatus.untracked}
|
||||
onCommit={() => {
|
||||
onRefresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mobileTab === "terminal" && selectedRepoId && (
|
||||
<InstanceList
|
||||
projectId={projectId}
|
||||
repoId={selectedRepoId}
|
||||
projectName={project?.name}
|
||||
repoName={selectedRepo?.name}
|
||||
toolTypes={toolTypes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mobile-workspace-tabs">
|
||||
<button
|
||||
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
|
||||
onClick={() => onMobileTabChange("files")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="folder" size="sm" />
|
||||
<span>Files</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
|
||||
onClick={() => onMobileTabChange("editor")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
<span>Editor</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
|
||||
onClick={() => onMobileTabChange("git")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="branch" size="sm" />
|
||||
<span>Git</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
|
||||
onClick={() => onMobileTabChange("terminal")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="terminal" size="sm" />
|
||||
<span>Terminal</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedRepoId && (
|
||||
<GitToolbar
|
||||
projectId={projectId}
|
||||
repoId={selectedRepoId}
|
||||
currentBranch={currentBranch}
|
||||
branches={branches}
|
||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||
isMirror={Boolean(selectedRepo?.is_mirror)}
|
||||
onBranchChange={onBranchChange}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)}
|
||||
<div className="workspace-layout">
|
||||
<aside className="workspace-sidebar">
|
||||
<div className="sidebar-section">
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepoId || ""}
|
||||
onChange={(e) => onRepoChange(e.target.value)}
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedRepoId && (
|
||||
<>
|
||||
<FileBrowser projectId={projectId} repoId={selectedRepoId} gitStatus={gitStatus} />
|
||||
{gitStatus && (
|
||||
<CommitPanel
|
||||
projectId={projectId}
|
||||
repoId={selectedRepoId}
|
||||
modified={gitStatus.modified}
|
||||
added={gitStatus.added}
|
||||
deleted={gitStatus.deleted}
|
||||
untracked={gitStatus.untracked}
|
||||
onCommit={onRefresh}
|
||||
/>
|
||||
)}
|
||||
<InstanceList
|
||||
projectId={projectId}
|
||||
repoId={selectedRepoId}
|
||||
projectName={project?.name}
|
||||
repoName={selectedRepo?.name}
|
||||
toolTypes={toolTypes}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="workspace-main">
|
||||
{selectedRepoId && (
|
||||
<FileEditor projectId={projectId} repoId={selectedRepoId} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
import {
|
||||
createConfigProfile,
|
||||
deleteConfigProfile,
|
||||
listConfigProfiles,
|
||||
previewConfigProfile,
|
||||
updateConfigProfile,
|
||||
updateProfileIncludes,
|
||||
type ConfigProfile,
|
||||
type CreateConfigProfileRequest,
|
||||
type ResolvedProfile,
|
||||
} from "../api/config-profiles";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listToolTypes, type ToolType } from "../api/tool-types";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
|
||||
type Status = "loading" | "ready" | "error";
|
||||
type SaveStatus = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
const defaultForm: CreateConfigProfileRequest = {
|
||||
name: "",
|
||||
description: "",
|
||||
env_vars: {},
|
||||
runtime_hints: {},
|
||||
mounts: [],
|
||||
git_mounts: [],
|
||||
files: {},
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
export const useConfigProfiles = () => {
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
|
||||
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [profs, projs, types] = await Promise.all([
|
||||
listConfigProfiles(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setProfiles(profs || []);
|
||||
setProjects(projs || []);
|
||||
setToolTypes(types || []);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData(defaultForm);
|
||||
setIncludedProfileIds([]);
|
||||
setError(null);
|
||||
setSaveStatus("idle");
|
||||
setPreviewData(null);
|
||||
};
|
||||
|
||||
const populateForm = (profile: ConfigProfile) => {
|
||||
setFormData({
|
||||
name: profile.name,
|
||||
description: profile.description || undefined,
|
||||
project_id: profile.project_id || undefined,
|
||||
tool_type_id: profile.tool_type_id || undefined,
|
||||
env_vars: profile.env_vars,
|
||||
runtime_hints: profile.runtime_hints,
|
||||
mounts: profile.mounts,
|
||||
git_mounts: profile.git_mounts || [],
|
||||
files: profile.files,
|
||||
is_default: profile.is_default,
|
||||
});
|
||||
setIncludedProfileIds(
|
||||
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id),
|
||||
);
|
||||
setError(null);
|
||||
setSaveStatus("idle");
|
||||
setPreviewData(null);
|
||||
};
|
||||
|
||||
const handleSelectProfile = (profile: ConfigProfile | null) => {
|
||||
if (profile) {
|
||||
setSelectedProfileId(profile.id);
|
||||
setIsCreating(false);
|
||||
populateForm(profile);
|
||||
} else {
|
||||
setSelectedProfileId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setSelectedProfileId(null);
|
||||
setIsCreating(true);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const getIncludedProfile = (id: string): ConfigProfile | undefined =>
|
||||
profiles.find((p) => p.id === id);
|
||||
|
||||
const getScopeLabel = (profile: ConfigProfile): string => {
|
||||
if (profile.project_id && profile.tool_type_id) return "Project + Tool";
|
||||
if (profile.project_id) return "Project";
|
||||
if (profile.tool_type_id) return "Tool";
|
||||
return "Global";
|
||||
};
|
||||
|
||||
const wouldCreateCycle = (
|
||||
profileId: string,
|
||||
targetId: string,
|
||||
visited = new Set<string>(),
|
||||
): boolean => {
|
||||
if (visited.has(targetId)) return true;
|
||||
const target = getIncludedProfile(targetId);
|
||||
if (!target) return false;
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(targetId);
|
||||
for (const inc of target.includes) {
|
||||
if (
|
||||
inc.included_profile_id === profileId ||
|
||||
wouldCreateCycle(profileId, inc.included_profile_id, nextVisited)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const availableProfilesForInclude = (): ConfigProfile[] => {
|
||||
const currentId = selectedProfile?.id;
|
||||
if (!currentId) return [];
|
||||
return profiles.filter((p) => {
|
||||
if (p.id === currentId) return false;
|
||||
if (includedProfileIds.includes(p.id)) return false;
|
||||
if (wouldCreateCycle(currentId, p.id)) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const addInclude = (profileId: string) => {
|
||||
setIncludedProfileIds((prev) => [...prev, profileId]);
|
||||
};
|
||||
|
||||
const removeInclude = (index: number) => {
|
||||
setIncludedProfileIds((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||||
e.preventDefault();
|
||||
const dragIndex = Number(e.dataTransfer.getData("text/plain"));
|
||||
if (dragIndex === dropIndex) {
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
setIncludedProfileIds((prev) => {
|
||||
const newOrder = [...prev];
|
||||
const [removed] = newOrder.splice(dragIndex, 1);
|
||||
newOrder.splice(dropIndex, 0, removed);
|
||||
return newOrder;
|
||||
});
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
setError(null);
|
||||
setSaveStatus("saving");
|
||||
|
||||
if (!formData.name?.trim()) {
|
||||
setError("Name is required");
|
||||
setSaveStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isCreating) {
|
||||
const newProfile = await createConfigProfile(formData);
|
||||
if (includedProfileIds.length > 0) {
|
||||
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
|
||||
}
|
||||
setIsCreating(false);
|
||||
setSelectedProfileId(newProfile.id);
|
||||
setSaveStatus("saved");
|
||||
await loadData();
|
||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
|
||||
if (refreshed) populateForm(refreshed);
|
||||
} else if (selectedProfile) {
|
||||
await updateConfigProfile(selectedProfile.id, formData);
|
||||
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
|
||||
setSaveStatus("saved");
|
||||
await loadData();
|
||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
|
||||
if (refreshed) populateForm(refreshed);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
setSaveStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
|
||||
try {
|
||||
await deleteConfigProfile(id);
|
||||
if (selectedProfileId === id) {
|
||||
setSelectedProfileId(null);
|
||||
setIsCreating(false);
|
||||
resetForm();
|
||||
}
|
||||
await loadData();
|
||||
} catch {
|
||||
alert("Failed to delete config profile");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = async (id: string) => {
|
||||
try {
|
||||
setPreviewingId(id);
|
||||
const data = await previewConfigProfile(id);
|
||||
setPreviewData(data);
|
||||
} catch {
|
||||
setError("Failed to preview config profile");
|
||||
} finally {
|
||||
setPreviewingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateFormField = <K extends keyof CreateConfigProfileRequest>(
|
||||
key: K,
|
||||
value: CreateConfigProfileRequest[K],
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const addEnvVar = () => {
|
||||
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
|
||||
setFormData((prev) => {
|
||||
const envVars = { ...prev.env_vars };
|
||||
if (oldKey !== newKey) delete envVars[oldKey];
|
||||
envVars[newKey] = value;
|
||||
return { ...prev, env_vars: envVars };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const removeEnvVar = (key: string) => {
|
||||
setFormData((prev) => {
|
||||
const envVars = { ...prev.env_vars };
|
||||
delete envVars[key];
|
||||
return { ...prev, env_vars: envVars };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const addFile = () => {
|
||||
setFormData((prev) => ({ ...prev, files: { ...prev.files, "": "" } }));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const updateFile = (oldPath: string, newPath: string, content: string) => {
|
||||
setFormData((prev) => {
|
||||
const files = { ...prev.files };
|
||||
if (oldPath !== newPath) delete files[oldPath];
|
||||
files[newPath] = content;
|
||||
return { ...prev, files };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const removeFile = (path: string) => {
|
||||
setFormData((prev) => {
|
||||
const files = { ...prev.files };
|
||||
delete files[path];
|
||||
return { ...prev, files };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const addMount = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
|
||||
}));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
mounts[index] = { ...mounts[index], ...updates };
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const removeMount = (index: number) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
mounts.splice(index, 1);
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const addMountFile = (mountIndex: number) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
mounts[mountIndex] = {
|
||||
...mounts[mountIndex],
|
||||
files: { ...mounts[mountIndex].files, "": "" },
|
||||
};
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const updateMountFile = (
|
||||
mountIndex: number,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
content: string,
|
||||
) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
const files = { ...mounts[mountIndex].files };
|
||||
if (oldPath !== newPath) delete files[oldPath];
|
||||
files[newPath] = content;
|
||||
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const removeMountFile = (mountIndex: number, path: string) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
const files = { ...mounts[mountIndex].files };
|
||||
delete files[path];
|
||||
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
profiles,
|
||||
projects,
|
||||
toolTypes,
|
||||
selectedProfile,
|
||||
selectedProfileId,
|
||||
isCreating,
|
||||
saveStatus,
|
||||
error,
|
||||
previewData,
|
||||
previewingId,
|
||||
formData,
|
||||
includedProfileIds,
|
||||
dragOverIndex,
|
||||
loadData,
|
||||
handleSelectProfile,
|
||||
handleCreateNew,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handlePreview,
|
||||
updateFormField,
|
||||
addEnvVar,
|
||||
updateEnvVar,
|
||||
removeEnvVar,
|
||||
addFile,
|
||||
updateFile,
|
||||
removeFile,
|
||||
addMount,
|
||||
updateMount,
|
||||
removeMount,
|
||||
addMountFile,
|
||||
updateMountFile,
|
||||
removeMountFile,
|
||||
getIncludedProfile,
|
||||
getScopeLabel,
|
||||
availableProfilesForInclude,
|
||||
addInclude,
|
||||
removeInclude,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
setPreviewData,
|
||||
setFormData,
|
||||
setSaveStatus,
|
||||
setIncludedProfileIds,
|
||||
populateForm,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
listProjects,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
} from "../api/projects";
|
||||
import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
|
||||
import { useAsyncData } from "./use-async-data";
|
||||
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
||||
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const useProjects = () => {
|
||||
const {
|
||||
data: projects,
|
||||
status,
|
||||
reload,
|
||||
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
|
||||
null,
|
||||
);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [expandedProject, setExpandedProject] = useState<string | null>(null);
|
||||
const [creatingWorkspace, setCreatingWorkspace] = useState<{
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
} | null>(null);
|
||||
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
|
||||
|
||||
const safeProjects = projects ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
setFormDescription("");
|
||||
setFormError(null);
|
||||
setEditingProject(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
const openEdit = (project: ProjectWithRepos) => {
|
||||
setFormName(project.name);
|
||||
setFormDescription(project.description ?? "");
|
||||
setFormError(null);
|
||||
setEditingProject(project);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingProject(null);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim()) {
|
||||
setFormError("Project name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (dialogMode === "create") {
|
||||
const input: ProjectCreateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await createProject(input);
|
||||
} else if (dialogMode === "edit" && editingProject) {
|
||||
const input: ProjectUpdateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await updateProject(editingProject.id, input);
|
||||
}
|
||||
closeDialog();
|
||||
reload();
|
||||
} catch {
|
||||
setFormError("Failed to save project");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (projectId: string) => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteConfirmId(null);
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncWorkspace = async (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: WorkspaceSummary,
|
||||
) => {
|
||||
setWorkspaceLoading(workspace.id);
|
||||
try {
|
||||
await syncWorkspace(projectId, repoId, workspace.id);
|
||||
reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to sync workspace");
|
||||
} finally {
|
||||
setWorkspaceLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = async (workspace: WorkspaceSummary) => {
|
||||
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
|
||||
setWorkspaceLoading(workspace.id);
|
||||
try {
|
||||
await deleteWorkspace(workspace.id);
|
||||
reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to delete workspace");
|
||||
} finally {
|
||||
setWorkspaceLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
projects: safeProjects,
|
||||
status,
|
||||
reload,
|
||||
dialogMode,
|
||||
formName,
|
||||
setFormName,
|
||||
formDescription,
|
||||
setFormDescription,
|
||||
formError,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
expandedProject,
|
||||
setExpandedProject,
|
||||
creatingWorkspace,
|
||||
setCreatingWorkspace,
|
||||
workspaceLoading,
|
||||
openCreate,
|
||||
openEdit,
|
||||
closeDialog,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handleSyncWorkspace,
|
||||
handleDeleteWorkspace,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { apiClient } from "../api/client";
|
||||
import {
|
||||
getRepositoryStatus,
|
||||
listRepositories,
|
||||
type GitRepository,
|
||||
type GitStatus,
|
||||
} from "../api/git-repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool-types";
|
||||
|
||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export const useRepoWorkspace = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
|
||||
searchParams.get("repo")
|
||||
);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [currentBranch, setCurrentBranch] = useState<string>("main");
|
||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
|
||||
const loadProject = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const response = await apiClient.get(`/projects/${projectId}`);
|
||||
setProject(response.data);
|
||||
} catch {
|
||||
setProject(null);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
if (data.length === 0) {
|
||||
setStatus("empty");
|
||||
} else {
|
||||
setStatus("ready");
|
||||
if (!selectedRepoId) {
|
||||
setSelectedRepoId(data[0].id);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", data[0].id);
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!projectId || !selectedRepoId) return;
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
|
||||
);
|
||||
const branchList = response.data.branches.map((b: { name: string }) => b.name);
|
||||
setBranches(branchList);
|
||||
const defaultBranch = response.data.default_branch;
|
||||
if (defaultBranch) setCurrentBranch(defaultBranch);
|
||||
} catch {
|
||||
setBranches([]);
|
||||
}
|
||||
}, [projectId, selectedRepoId]);
|
||||
|
||||
const loadGitStatus = useCallback(async () => {
|
||||
if (!projectId || !selectedRepoId) return;
|
||||
try {
|
||||
const data = await getRepositoryStatus(projectId, selectedRepoId);
|
||||
setGitStatus(data);
|
||||
} catch {
|
||||
setGitStatus(null);
|
||||
}
|
||||
}, [projectId, selectedRepoId]);
|
||||
|
||||
const loadToolTypes = useCallback(async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch {
|
||||
setToolTypes([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProject();
|
||||
void loadRepositories();
|
||||
void loadToolTypes();
|
||||
}, [loadProject, loadRepositories, loadToolTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
}, [loadBranches, loadGitStatus]);
|
||||
|
||||
const handleRepoChange = (repoId: string) => {
|
||||
setSelectedRepoId(repoId);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", repoId);
|
||||
newParams.delete("branch");
|
||||
newParams.delete("path");
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const handleBranchChange = (branch: string) => {
|
||||
setCurrentBranch(branch);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("branch", branch);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
project,
|
||||
status,
|
||||
repositories,
|
||||
selectedRepoId,
|
||||
selectedRepo,
|
||||
branches,
|
||||
currentBranch,
|
||||
gitStatus,
|
||||
toolTypes,
|
||||
handleRepoChange,
|
||||
handleBranchChange,
|
||||
loadGitStatus,
|
||||
loadBranches,
|
||||
loadRepositories,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh-keys";
|
||||
import { useAsyncData } from "./use-async-data";
|
||||
|
||||
export const useSSHKeys = () => {
|
||||
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||
const [signatures, setSignatures] = useState<Record<string, string>>({});
|
||||
const [signing, setSigning] = useState<Record<string, boolean>>({});
|
||||
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
|
||||
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
|
||||
const safeKeys = keys ?? [];
|
||||
|
||||
async function handleGenerate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newKeyName.trim()) return;
|
||||
try {
|
||||
setGenerating(true);
|
||||
await createSSHKey({ name: newKeyName.trim() });
|
||||
setNewKeyName("");
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setMutationError("Failed to generate SSH key");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(keyId: string) {
|
||||
if (!confirm("Are you sure you want to delete this SSH key?")) return;
|
||||
try {
|
||||
await deleteSSHKey(keyId);
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setMutationError("Failed to delete SSH key");
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
async function handleSign(keyId: string) {
|
||||
const payload = signPayloads[keyId];
|
||||
if (!payload?.trim()) return;
|
||||
try {
|
||||
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
||||
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||
setMutationError(null);
|
||||
} catch {
|
||||
setMutationError("Failed to sign payload");
|
||||
} finally {
|
||||
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify(keyId: string) {
|
||||
const payload = verifyPayloads[keyId];
|
||||
const signature = verifySignatures[keyId];
|
||||
if (!payload?.trim() || !signature?.trim()) return;
|
||||
try {
|
||||
setVerifying((prev) => ({ ...prev, [keyId]: true }));
|
||||
const result = await verifySignature(keyId, {
|
||||
payload: payload.trim(),
|
||||
signature: signature.trim(),
|
||||
});
|
||||
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||
setMutationError(null);
|
||||
} catch {
|
||||
setMutationError("Failed to verify signature");
|
||||
} finally {
|
||||
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
keys: safeKeys,
|
||||
status,
|
||||
loadKeys,
|
||||
newKeyName,
|
||||
setNewKeyName,
|
||||
generating,
|
||||
mutationError,
|
||||
setMutationError,
|
||||
signPayloads,
|
||||
setSignPayloads,
|
||||
signatures,
|
||||
signing,
|
||||
verifyPayloads,
|
||||
setVerifyPayloads,
|
||||
verifySignatures,
|
||||
setVerifySignatures,
|
||||
verifyResults,
|
||||
verifying,
|
||||
handleGenerate,
|
||||
handleDelete,
|
||||
copyToClipboard,
|
||||
handleSign,
|
||||
handleVerify,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import type { TerminalRef } from "../components/features/terminal/terminal";
|
||||
import type { TerminalSessionInfo } from "../components/features/terminal/terminal-session-tabs";
|
||||
import { useMobileViewport } from "./use-mobile-viewport";
|
||||
import { useAutoHide } from "./use-auto-hide";
|
||||
import { useVirtualKeyboard } from "./use-virtual-keyboard";
|
||||
import { useTerminalSessions } from "./use-terminal-sessions";
|
||||
import type { TerminalSession } from "../api/terminal";
|
||||
import type { ModifierKey } from "./use-special-keys";
|
||||
|
||||
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
||||
sessions.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status as TerminalSessionInfo["status"],
|
||||
}));
|
||||
|
||||
type TerminalStatus =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error"
|
||||
| "resetting";
|
||||
|
||||
export const useTerminalPage = () => {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMobileViewport();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||
|
||||
const [terminalStatuses, setTerminalStatuses] = useState<
|
||||
Record<string, TerminalStatus>
|
||||
>({});
|
||||
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
||||
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
||||
const focusInputRef = useRef<(() => void) | null>(null);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } = useVirtualKeyboard();
|
||||
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
resetSession,
|
||||
loading,
|
||||
error,
|
||||
} = useTerminalSessions(instanceId ?? "");
|
||||
|
||||
// Auto-create default session
|
||||
useEffect(() => {
|
||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||
void createSession("Session 1");
|
||||
}
|
||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||
|
||||
// Sync refs with sessions
|
||||
useEffect(() => {
|
||||
for (const session of sessions) {
|
||||
if (!terminalRefs.current[session.id]) {
|
||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
||||
}
|
||||
}
|
||||
const currentIds = new Set(sessions.map((s) => s.id));
|
||||
for (const id of Object.keys(terminalRefs.current)) {
|
||||
if (!currentIds.has(id)) {
|
||||
delete terminalRefs.current[id];
|
||||
}
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
// Fit and focus active terminal
|
||||
useEffect(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
const ref = terminalRefs.current[activeSessionId];
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => {
|
||||
ref.current?.fit();
|
||||
ref.current?.focus();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
};
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||
if (!isAltShift) return;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "n":
|
||||
e.preventDefault();
|
||||
if (sessions.length < 5) {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}
|
||||
break;
|
||||
case "w":
|
||||
e.preventDefault();
|
||||
if (activeSessionId && window.confirm("Close this terminal session?")) {
|
||||
void closeSession(activeSessionId);
|
||||
}
|
||||
break;
|
||||
case "arrowleft":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
||||
}
|
||||
break;
|
||||
case "arrowright":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx < sessions.length - 1) setActiveSessionId(sessions[idx + 1].id);
|
||||
}
|
||||
break;
|
||||
case "r":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) void resetSession(activeSessionId);
|
||||
break;
|
||||
case "f":
|
||||
e.preventDefault();
|
||||
setIsFullscreen((prev) => !prev);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
sessions,
|
||||
activeSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
resetSession,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
|
||||
// Keep screen awake
|
||||
useEffect(() => {
|
||||
let wakeLock: WakeLockSentinel | null = null;
|
||||
const requestWakeLock = async () => {
|
||||
try {
|
||||
if ("wakeLock" in navigator) {
|
||||
wakeLock = await navigator.wakeLock.request("screen");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void requestWakeLock();
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") void requestWakeLock();
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
wakeLock?.release().catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Lock page scroll on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
document.documentElement.classList.add("terminal-page-open");
|
||||
document.body.classList.add("terminal-page-open");
|
||||
return () => {
|
||||
document.documentElement.classList.remove("terminal-page-open");
|
||||
document.body.classList.remove("terminal-page-open");
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
const handleFullscreenClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!isFullscreen) return;
|
||||
const target = e.target as Node;
|
||||
const current = e.currentTarget as HTMLElement;
|
||||
const content = current.querySelector(".terminal-page-content");
|
||||
const header = current.querySelector(".terminal-fullscreen-header");
|
||||
if (content?.contains(target) || header?.contains(target)) return;
|
||||
setIsFullscreen(false);
|
||||
},
|
||||
[isFullscreen],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(sessionId: string) => setActiveSessionId(sessionId),
|
||||
[setActiveSessionId],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
async (sessionId: string) => closeSession(sessionId),
|
||||
[closeSession],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}, [createSession, sessions.length]);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(sessionId: string, newName: string) => {
|
||||
void renameSession(sessionId, newName);
|
||||
},
|
||||
[renameSession],
|
||||
);
|
||||
|
||||
const handleTerminalReady = useCallback(
|
||||
(
|
||||
sendData: (data: string) => void,
|
||||
status: TerminalStatus,
|
||||
focusInput: () => void,
|
||||
changeFontSize: (delta: number) => void,
|
||||
) => {
|
||||
setTerminalStatuses((prev) => ({
|
||||
...prev,
|
||||
[activeSessionId ?? "default"]: status,
|
||||
}));
|
||||
sendDataRef.current = sendData;
|
||||
focusInputRef.current = focusInput;
|
||||
changeFontSizeRef.current = changeFontSize;
|
||||
},
|
||||
[activeSessionId],
|
||||
);
|
||||
|
||||
const handleFontSizeChange = useCallback((delta: number) => {
|
||||
changeFontSizeRef.current?.(delta);
|
||||
}, []);
|
||||
|
||||
const handleSendKey = useCallback((data: string) => {
|
||||
sendDataRef.current?.(data);
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
terminalRefs.current[activeSessionId].current?.reset();
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
return {
|
||||
instanceId,
|
||||
navigate,
|
||||
isMobile,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
terminalRefs,
|
||||
headerAutoHide,
|
||||
terminalStatuses,
|
||||
sendDataRef,
|
||||
focusInputRef,
|
||||
changeFontSizeRef,
|
||||
showResetConfirm,
|
||||
setShowResetConfirm,
|
||||
showSpecialKeysPanel,
|
||||
setShowSpecialKeysPanel,
|
||||
activeModifier,
|
||||
setActiveModifier,
|
||||
isKeyboardOpen,
|
||||
keyboardHeight,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
loading,
|
||||
error,
|
||||
handleFullscreenClick,
|
||||
handleSelect,
|
||||
handleClose,
|
||||
handleCreate,
|
||||
handleRename,
|
||||
handleTerminalReady,
|
||||
handleFontSizeChange,
|
||||
handleSendKey,
|
||||
handleReset,
|
||||
sessionInfos: SESSIONS_TO_INFO(sessions),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
import {
|
||||
createToolType,
|
||||
deleteToolType,
|
||||
listToolTypes,
|
||||
updateToolType,
|
||||
type CreateToolTypeRequest,
|
||||
type ReadinessProbe,
|
||||
type ToolType,
|
||||
type UpdateToolTypeRequest,
|
||||
} from "../api/tool-types";
|
||||
import {
|
||||
createToolDefinition,
|
||||
getToolDefinition,
|
||||
listToolDefinitions,
|
||||
updateToolDefinition,
|
||||
} from "../api/tool-definitions";
|
||||
import type { ToolTypeFormState } from "../components/features/tool-workshop/ToolTypeEditorPanel";
|
||||
|
||||
type Status = "loading" | "ready" | "error";
|
||||
|
||||
const defaultForm: ToolTypeFormState = {
|
||||
name: "",
|
||||
display_name: "",
|
||||
description: "",
|
||||
category: "",
|
||||
interface_type: "web",
|
||||
requires_port: true,
|
||||
default_port: "",
|
||||
definition_type: "compose",
|
||||
compose_template: "",
|
||||
dockerfile_template: "",
|
||||
readiness_command: "",
|
||||
readiness_timeout: "30",
|
||||
readiness_interval: "2",
|
||||
required_variables: "",
|
||||
startup_command: "",
|
||||
};
|
||||
|
||||
export const useToolWorkshop = () => {
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [baseDefinitions, setBaseDefinitions] = useState<
|
||||
Awaited<ReturnType<typeof listToolDefinitions>>
|
||||
>([]);
|
||||
const [manifestData, setManifestData] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null);
|
||||
const [manifestDefinitionId, setManifestDefinitionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedToolTypeId, setSelectedToolTypeId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [toolTypeForm, setToolTypeForm] = useState<ToolTypeFormState>(defaultForm);
|
||||
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
||||
const [toolTypeDirty, setToolTypeDirty] = useState(false);
|
||||
|
||||
const selectedToolType =
|
||||
(toolTypes || []).find((t) => t.id === selectedToolTypeId) || null;
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [types, defs] = await Promise.all([
|
||||
listToolTypes(),
|
||||
listToolDefinitions(),
|
||||
]);
|
||||
setToolTypes(types || []);
|
||||
setBaseDefinitions((defs || []).filter((d) => d.is_base));
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const resetToolTypeForm = () => {
|
||||
setToolTypeForm(defaultForm);
|
||||
setToolTypeError(null);
|
||||
setToolTypeDirty(false);
|
||||
setManifestData(null);
|
||||
setManifestDefinitionId(null);
|
||||
};
|
||||
|
||||
const populateToolTypeForm = async (toolType: ToolType) => {
|
||||
setToolTypeForm({
|
||||
name: toolType.name,
|
||||
display_name: toolType.display_name,
|
||||
description: toolType.description || "",
|
||||
category: toolType.category || "",
|
||||
interface_type: (toolType.interface_type as "web" | "terminal") || "web",
|
||||
requires_port: toolType.requires_port ?? true,
|
||||
default_port: toolType.default_port?.toString() || "",
|
||||
definition_type:
|
||||
(toolType.definition_type as "compose" | "dockerfile" | "manifest") ||
|
||||
"compose",
|
||||
compose_template: toolType.compose_template || "",
|
||||
dockerfile_template: toolType.dockerfile_template || "",
|
||||
readiness_command: toolType.readiness_probe?.command || "",
|
||||
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
|
||||
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
|
||||
required_variables: toolType.required_variables?.join(", ") || "",
|
||||
startup_command: toolType.startup_command || "",
|
||||
});
|
||||
setToolTypeError(null);
|
||||
setToolTypeDirty(false);
|
||||
setManifestDefinitionId(toolType.manifest_id || null);
|
||||
|
||||
if (toolType.definition_type === "manifest" && toolType.manifest_id) {
|
||||
try {
|
||||
const defn = await getToolDefinition(toolType.manifest_id);
|
||||
setManifestData(defn.manifest);
|
||||
} catch {
|
||||
setManifestData(null);
|
||||
}
|
||||
} else {
|
||||
setManifestData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectToolType = (toolType: ToolType | null) => {
|
||||
if (toolTypeDirty) {
|
||||
if (!window.confirm("You have unsaved changes. Discard them?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (toolType) {
|
||||
setSelectedToolTypeId(toolType.id);
|
||||
setIsCreating(false);
|
||||
void populateToolTypeForm(toolType);
|
||||
} else {
|
||||
setSelectedToolTypeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
if (toolTypeDirty) {
|
||||
if (!window.confirm("You have unsaved changes. Discard them?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSelectedToolTypeId(null);
|
||||
setIsCreating(true);
|
||||
resetToolTypeForm();
|
||||
};
|
||||
|
||||
const handleToolTypeSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
setToolTypeError(null);
|
||||
|
||||
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
|
||||
setToolTypeError("Name and display name are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
toolTypeForm.requires_port &&
|
||||
(!toolTypeForm.default_port.trim() ||
|
||||
isNaN(Number(toolTypeForm.default_port)))
|
||||
) {
|
||||
setToolTypeError("Default port is required and must be a number");
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolTypeForm.definition_type !== "manifest") {
|
||||
const template =
|
||||
toolTypeForm.definition_type === "compose"
|
||||
? toolTypeForm.compose_template
|
||||
: toolTypeForm.dockerfile_template;
|
||||
|
||||
if (!template.trim()) {
|
||||
setToolTypeError(
|
||||
`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (!manifestData) {
|
||||
setToolTypeError(
|
||||
"Manifest data is required for manifest definition type",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = toolTypeForm.required_variables
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
|
||||
const readinessProbe: ReadinessProbe | undefined =
|
||||
toolTypeForm.readiness_command.trim()
|
||||
? {
|
||||
command: toolTypeForm.readiness_command.trim(),
|
||||
timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
|
||||
interval: parseInt(toolTypeForm.readiness_interval) || 2,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const template =
|
||||
toolTypeForm.definition_type === "compose"
|
||||
? toolTypeForm.compose_template
|
||||
: toolTypeForm.dockerfile_template;
|
||||
|
||||
try {
|
||||
if (isCreating) {
|
||||
let manifestId: string | undefined;
|
||||
if (toolTypeForm.definition_type === "manifest" && manifestData) {
|
||||
const manifestPayload = {
|
||||
name: toolTypeForm.name.trim(),
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interface_type: toolTypeForm.interface_type,
|
||||
base_image: (manifestData.base_image as string) || undefined,
|
||||
base_definition_id:
|
||||
(manifestData.base_definition_id as string) || undefined,
|
||||
manifest: manifestData,
|
||||
};
|
||||
const newManifest = await createToolDefinition(manifestPayload);
|
||||
manifestId = newManifest.id;
|
||||
}
|
||||
|
||||
const input: CreateToolTypeRequest = {
|
||||
name: toolTypeForm.name.trim(),
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interface_type: toolTypeForm.interface_type,
|
||||
requires_port: toolTypeForm.requires_port,
|
||||
default_port: toolTypeForm.requires_port
|
||||
? Number(toolTypeForm.default_port)
|
||||
: 0,
|
||||
definition_type: toolTypeForm.definition_type,
|
||||
manifest_id: manifestId,
|
||||
compose_template:
|
||||
toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||
dockerfile_template:
|
||||
toolTypeForm.definition_type === "dockerfile"
|
||||
? template
|
||||
: undefined,
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
startup_command: toolTypeForm.startup_command.trim() || undefined,
|
||||
};
|
||||
const newTool = await createToolType(input);
|
||||
setIsCreating(false);
|
||||
setSelectedToolTypeId(newTool.id);
|
||||
setToolTypeDirty(false);
|
||||
} else if (selectedToolType) {
|
||||
let manifestId = selectedToolType.manifest_id || undefined;
|
||||
if (toolTypeForm.definition_type === "manifest" && manifestData) {
|
||||
if (manifestId) {
|
||||
await updateToolDefinition(manifestId, {
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
manifest: manifestData,
|
||||
});
|
||||
} else {
|
||||
const manifestPayload = {
|
||||
name: toolTypeForm.name.trim(),
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interface_type: toolTypeForm.interface_type,
|
||||
base_image: (manifestData.base_image as string) || undefined,
|
||||
base_definition_id:
|
||||
(manifestData.base_definition_id as string) || undefined,
|
||||
manifest: manifestData,
|
||||
};
|
||||
const newManifest = await createToolDefinition(manifestPayload);
|
||||
manifestId = newManifest.id;
|
||||
}
|
||||
}
|
||||
|
||||
const input: UpdateToolTypeRequest = {
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interface_type: toolTypeForm.interface_type,
|
||||
requires_port: toolTypeForm.requires_port,
|
||||
default_port: toolTypeForm.requires_port
|
||||
? Number(toolTypeForm.default_port)
|
||||
: 0,
|
||||
definition_type: toolTypeForm.definition_type,
|
||||
manifest_id:
|
||||
toolTypeForm.definition_type === "manifest"
|
||||
? manifestId
|
||||
: undefined,
|
||||
compose_template:
|
||||
toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||
dockerfile_template:
|
||||
toolTypeForm.definition_type === "dockerfile"
|
||||
? template
|
||||
: undefined,
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
startup_command: toolTypeForm.startup_command.trim() || undefined,
|
||||
};
|
||||
await updateToolType(selectedToolType.id, input);
|
||||
setToolTypeDirty(false);
|
||||
}
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setToolTypeError(extractErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteToolType = async (id: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Delete this tool type? All associated configs will be removed.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await deleteToolType(id);
|
||||
if (selectedToolTypeId === id) {
|
||||
setSelectedToolTypeId(null);
|
||||
setIsCreating(false);
|
||||
resetToolTypeForm();
|
||||
}
|
||||
await loadData();
|
||||
} catch {
|
||||
alert("Failed to delete tool type");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormChange = (changes: Partial<ToolTypeFormState>) => {
|
||||
setToolTypeForm((prev) => ({ ...prev, ...changes }));
|
||||
setToolTypeDirty(true);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (isCreating) {
|
||||
resetToolTypeForm();
|
||||
} else if (selectedToolType) {
|
||||
void populateToolTypeForm(selectedToolType);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
toolTypes,
|
||||
baseDefinitions,
|
||||
selectedToolType,
|
||||
selectedToolTypeId,
|
||||
isCreating,
|
||||
toolTypeForm,
|
||||
manifestData,
|
||||
manifestDefinitionId,
|
||||
toolTypeError,
|
||||
toolTypeDirty,
|
||||
loadData,
|
||||
handleSelectToolType,
|
||||
handleCreateNew,
|
||||
handleToolTypeSubmit,
|
||||
handleDeleteToolType,
|
||||
handleFormChange,
|
||||
handleReset,
|
||||
setManifestData,
|
||||
setToolTypeDirty,
|
||||
};
|
||||
};
|
||||
+19
-10
@@ -5,16 +5,25 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { AppRouter } from "./router";
|
||||
import { AuthProvider } from "./state/auth";
|
||||
import { SessionsProvider } from "./state/sessions";
|
||||
import "./styles.css";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/global.css";
|
||||
import "./styles/utilities.css";
|
||||
import "./styles/syntax-highlight.css";
|
||||
import "./styles/pages/git-history.css";
|
||||
import "./styles/pages/repo-workspace.css";
|
||||
import "./styles/pages/projects.css";
|
||||
import "./styles/pages/sessions.css";
|
||||
import "./styles/pages/ssh-keys.css";
|
||||
import "./styles/pages/workspace-detail.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<SessionsProvider>
|
||||
<AppRouter />
|
||||
</SessionsProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<SessionsProvider>
|
||||
<AppRouter />
|
||||
</SessionsProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HomePage } from "./dashboard";
|
||||
import { HomePage } from "./DashboardPage";
|
||||
|
||||
const mockDashboard = vi.fn();
|
||||
const mockSessions = vi.fn();
|
||||
@@ -10,72 +10,79 @@ const mockProjects = vi.fn();
|
||||
const mockRepos = vi.fn();
|
||||
|
||||
vi.mock("../api/dashboard", () => ({
|
||||
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
|
||||
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../api/sessions", () => ({
|
||||
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
||||
createInstance: vi.fn(),
|
||||
startInstance: vi.fn(),
|
||||
stopInstance: vi.fn(),
|
||||
deleteInstance: vi.fn(),
|
||||
recreateInstanceTunnel: vi.fn()
|
||||
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
||||
createInstance: vi.fn(),
|
||||
startInstance: vi.fn(),
|
||||
stopInstance: vi.fn(),
|
||||
deleteInstance: vi.fn(),
|
||||
recreateInstanceTunnel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/projects", () => ({
|
||||
listProjects: (...args: unknown[]) => mockProjects(...args)
|
||||
listProjects: (...args: unknown[]) => mockProjects(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../api/git-repositories", () => ({
|
||||
listRepositories: (...args: unknown[]) => mockRepos(...args)
|
||||
listRepositories: (...args: unknown[]) => mockRepos(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../api/tool-types", () => ({
|
||||
listToolTypes: vi.fn().mockResolvedValue([])
|
||||
listToolTypes: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
describe("HomePage", () => {
|
||||
beforeEach(() => {
|
||||
mockDashboard.mockReset();
|
||||
mockSessions.mockReset();
|
||||
mockProjects.mockReset();
|
||||
mockRepos.mockReset();
|
||||
});
|
||||
beforeEach(() => {
|
||||
mockDashboard.mockReset();
|
||||
mockSessions.mockReset();
|
||||
mockProjects.mockReset();
|
||||
mockRepos.mockReset();
|
||||
});
|
||||
|
||||
it("shows overview sections", async () => {
|
||||
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
|
||||
mockSessions.mockResolvedValue([]);
|
||||
mockProjects.mockResolvedValue([]);
|
||||
mockRepos.mockResolvedValue([]);
|
||||
it("shows overview sections", async () => {
|
||||
mockDashboard.mockResolvedValue({
|
||||
projects: 1,
|
||||
repositories: 2,
|
||||
sshKeys: 3,
|
||||
recentActivity: [],
|
||||
});
|
||||
mockSessions.mockResolvedValue([]);
|
||||
mockProjects.mockResolvedValue([]);
|
||||
mockRepos.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Available projects")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Workspaces")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows retry action when home load fails", async () => {
|
||||
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
||||
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
||||
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
||||
it("shows retry action when home load fails", async () => {
|
||||
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
||||
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
||||
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
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,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProjectsPage } from "./projects";
|
||||
import { ProjectsPage } from "./ProjectsPage";
|
||||
import * as projectsApi from "../api/projects";
|
||||
|
||||
const mockProjects = [
|
||||
|
||||
@@ -1,143 +1,37 @@
|
||||
/** Projects page with inline repositories and workspaces. */
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
listProjects,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
} from "../api/projects";
|
||||
import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
} from "../components/data-states";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
||||
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
import { ProjectCard } from "../components/features/project/ProjectCard";
|
||||
import { ProjectDialog } from "../components/features/project/ProjectDialog";
|
||||
import { useProjects } from "../hooks/use-projects";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const {
|
||||
data: projects,
|
||||
projects,
|
||||
status,
|
||||
reload,
|
||||
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
|
||||
null,
|
||||
);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [expandedProject, setExpandedProject] = useState<string | null>(null);
|
||||
const [creatingWorkspace, setCreatingWorkspace] = useState<{
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
} | null>(null);
|
||||
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
|
||||
dialogMode,
|
||||
formName,
|
||||
setFormName,
|
||||
formDescription,
|
||||
setFormDescription,
|
||||
formError,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
expandedProject,
|
||||
setExpandedProject,
|
||||
creatingWorkspace,
|
||||
setCreatingWorkspace,
|
||||
workspaceLoading,
|
||||
openCreate,
|
||||
openEdit,
|
||||
closeDialog,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handleSyncWorkspace,
|
||||
handleDeleteWorkspace,
|
||||
} = useProjects();
|
||||
|
||||
const safeProjects = projects ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
setFormDescription("");
|
||||
setFormError(null);
|
||||
setEditingProject(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
const openEdit = (project: ProjectWithRepos) => {
|
||||
setFormName(project.name);
|
||||
setFormDescription(project.description ?? "");
|
||||
setFormError(null);
|
||||
setEditingProject(project);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingProject(null);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim()) {
|
||||
setFormError("Project name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (dialogMode === "create") {
|
||||
const input: ProjectCreateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await createProject(input);
|
||||
} else if (dialogMode === "edit" && editingProject) {
|
||||
const input: ProjectUpdateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await updateProject(editingProject.id, input);
|
||||
}
|
||||
closeDialog();
|
||||
reload();
|
||||
} catch {
|
||||
setFormError("Failed to save project");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (projectId: string) => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteConfirmId(null);
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncWorkspace = async (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: WorkspaceSummary,
|
||||
) => {
|
||||
setWorkspaceLoading(workspace.id);
|
||||
try {
|
||||
await syncWorkspace(projectId, repoId, workspace.id);
|
||||
reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to sync workspace");
|
||||
} finally {
|
||||
setWorkspaceLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = async (workspace: WorkspaceSummary) => {
|
||||
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
|
||||
setWorkspaceLoading(workspace.id);
|
||||
try {
|
||||
await deleteWorkspace(workspace.id);
|
||||
reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to delete workspace");
|
||||
} finally {
|
||||
setWorkspaceLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||
const isEmpty = status === "ready" && projects.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -159,13 +53,20 @@ export const ProjectsPage = () => {
|
||||
<EmptyState message="No projects yet. Create your first project above." />
|
||||
)}
|
||||
|
||||
{status === "ready" && safeProjects.length > 0 && (
|
||||
{status === "ready" && projects.length > 0 && (
|
||||
<div className="project-list">
|
||||
{safeProjects.map((project) => (
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
expanded={expandedProject === project.id}
|
||||
deleteConfirm={deleteConfirmId === project.id}
|
||||
workspaceLoading={workspaceLoading}
|
||||
showCreateForm={
|
||||
creatingWorkspace?.projectId === project.id
|
||||
? creatingWorkspace.repoId
|
||||
: null
|
||||
}
|
||||
onToggle={() =>
|
||||
setExpandedProject(
|
||||
expandedProject === project.id ? null : project.id,
|
||||
@@ -173,7 +74,6 @@ export const ProjectsPage = () => {
|
||||
}
|
||||
onEdit={() => openEdit(project)}
|
||||
onDelete={() => setDeleteConfirmId(project.id)}
|
||||
deleteConfirm={deleteConfirmId === project.id}
|
||||
onConfirmDelete={() => void handleDelete(project.id)}
|
||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||
onCreateWorkspace={(repoId) =>
|
||||
@@ -186,12 +86,6 @@ export const ProjectsPage = () => {
|
||||
void handleDeleteWorkspace(workspace);
|
||||
}
|
||||
}}
|
||||
workspaceLoading={workspaceLoading}
|
||||
showCreateForm={
|
||||
creatingWorkspace?.projectId === project.id
|
||||
? creatingWorkspace.repoId
|
||||
: null
|
||||
}
|
||||
onCancelCreate={() => setCreatingWorkspace(null)}
|
||||
onCreated={() => {
|
||||
setCreatingWorkspace(null);
|
||||
@@ -203,231 +97,17 @@ export const ProjectsPage = () => {
|
||||
)}
|
||||
|
||||
{dialogMode !== "none" && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>
|
||||
{dialogMode === "create" ? "Create Project" : "Edit Project"}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Description
|
||||
<textarea
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
{formError && <p className="error-text">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={closeDialog}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
{dialogMode === "create" ? (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectDialog
|
||||
mode={dialogMode}
|
||||
name={formName}
|
||||
description={formDescription}
|
||||
error={formError}
|
||||
onNameChange={setFormName}
|
||||
onDescriptionChange={setFormDescription}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─── Project Card ─── */
|
||||
|
||||
function ProjectCard({
|
||||
project,
|
||||
expanded,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirm,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onCreateWorkspace,
|
||||
onWorkspaceAction,
|
||||
workspaceLoading,
|
||||
showCreateForm,
|
||||
onCancelCreate,
|
||||
onCreated,
|
||||
}: {
|
||||
project: ProjectWithRepos;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
deleteConfirm: boolean;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onCreateWorkspace: (repoId: string) => void;
|
||||
onWorkspaceAction: (
|
||||
repoId: string,
|
||||
workspace: WorkspaceSummary,
|
||||
action: "sync" | "delete",
|
||||
) => void;
|
||||
workspaceLoading: string | null;
|
||||
onCancelCreate: () => void;
|
||||
showCreateForm: string | null;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
return (
|
||||
<article className="card project-card">
|
||||
<div className="project-info-row">
|
||||
<button
|
||||
className="project-toggle"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
|
||||
<h3>{project.name}</h3>
|
||||
{project.repositories.length > 0 && (
|
||||
<span className="repo-count">
|
||||
{project.repositories.length} repo
|
||||
{project.repositories.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="project-actions">
|
||||
<button className="ghost-button" onClick={onEdit} type="button">
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
{deleteConfirm ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={onConfirmDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={onCancelDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="project-detail">
|
||||
{project.repositories.length === 0 ? (
|
||||
<p className="muted">No repositories yet.</p>
|
||||
) : (
|
||||
<div className="repo-list">
|
||||
{project.repositories.map((repo) => (
|
||||
<div key={repo.id} className="repo-block">
|
||||
<div className="repo-header">
|
||||
<h4>{repo.name}</h4>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => onCreateWorkspace(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
</div>
|
||||
{showCreateForm === repo.id && (
|
||||
<WorkspaceCreateForm
|
||||
defaultProjectId={project.id}
|
||||
defaultRepoId={repo.id}
|
||||
onSubmit={onCreated}
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
)}
|
||||
{repo.workspaces.length === 0 ? (
|
||||
<p className="muted">No workspaces.</p>
|
||||
) : (
|
||||
<div className="workspace-grid">
|
||||
{repo.workspaces.map((ws) => (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`workspace-chip ${ws.status}`}
|
||||
>
|
||||
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
|
||||
<span className="ws-branch">
|
||||
<Icon name="branch" size="sm" /> {ws.branch}
|
||||
</span>
|
||||
{ws.instance_count > 0 && (
|
||||
<span className="ws-instances">
|
||||
{ws.instance_count} tool
|
||||
{ws.instance_count > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<div className="ws-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={workspaceLoading === ws.id}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(repo.id, ws, "sync")
|
||||
}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-text"
|
||||
disabled={workspaceLoading === ws.id}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(repo.id, ws, "delete")
|
||||
}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,505 +1,89 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { apiClient } from "../api/client";
|
||||
import {
|
||||
getRepositoryStatus,
|
||||
listRepositories,
|
||||
type GitRepository,
|
||||
type GitStatus,
|
||||
} from "../api/git-repositories";
|
||||
import { CommitPanel } from "../components/features/git/commit-panel";
|
||||
import { FileEditor } from "../components/features/git/file-editor";
|
||||
import { GitToolbar } from "../components/features/git/git-toolbar";
|
||||
import { InstanceList } from "../components/features/tool/instance-list";
|
||||
import { useRepoWorkspace } from "../hooks/use-repo-workspace";
|
||||
import { WorkspaceHeader } from "../components/features/workspace/workspace-header";
|
||||
import { listToolTypes } from "../api/tool-types";
|
||||
import type { ToolType } from "../api/tool-types";
|
||||
import { WorkspaceLayout } from "../components/features/workspace/WorkspaceLayout";
|
||||
|
||||
type MobileTab = "files" | "editor" | "git" | "terminal";
|
||||
|
||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||
|
||||
interface FileTreeEntry {
|
||||
name: string;
|
||||
type: "file" | "directory";
|
||||
path: string;
|
||||
size?: number;
|
||||
mode?: string;
|
||||
last_commit?: {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export const RepoWorkspace = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isMobile = useMobileViewport();
|
||||
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
||||
const {
|
||||
projectId,
|
||||
project,
|
||||
status,
|
||||
repositories,
|
||||
selectedRepoId,
|
||||
selectedRepo,
|
||||
branches,
|
||||
currentBranch,
|
||||
gitStatus,
|
||||
toolTypes,
|
||||
handleRepoChange,
|
||||
handleBranchChange,
|
||||
loadGitStatus,
|
||||
loadBranches,
|
||||
loadRepositories,
|
||||
} = useRepoWorkspace();
|
||||
const isMobile = useMobileViewport();
|
||||
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
||||
|
||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
|
||||
searchParams.get("repo")
|
||||
);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [currentBranch, setCurrentBranch] = useState<string>("main");
|
||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
return (
|
||||
<section className="repo-workspace">
|
||||
{project && (
|
||||
<WorkspaceHeader
|
||||
project={project}
|
||||
currentRepo={selectedRepo || null}
|
||||
/>
|
||||
)}
|
||||
|
||||
const loadProject = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const response = await apiClient.get(`/projects/${projectId}`);
|
||||
setProject(response.data);
|
||||
} catch {
|
||||
setProject(null);
|
||||
}
|
||||
}, [projectId]);
|
||||
{status === "loading" && (
|
||||
<LoadingState message="Loading repositories..." />
|
||||
)}
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Failed to load repositories"
|
||||
onRetry={() => void loadRepositories()}
|
||||
/>
|
||||
)}
|
||||
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
{status === "empty" && (
|
||||
<div className="card stack">
|
||||
<EmptyState message="No repositories in this project yet." />
|
||||
<Link
|
||||
className="primary-button"
|
||||
to={`/projects/${projectId}/settings/repositories`}
|
||||
>
|
||||
Manage Repositories
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
if (data.length === 0) {
|
||||
setStatus("empty");
|
||||
} else {
|
||||
setStatus("ready");
|
||||
// If no repo selected, select the first one
|
||||
if (!selectedRepoId) {
|
||||
setSelectedRepoId(data[0].id);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", data[0].id);
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!projectId || !selectedRepoId) return;
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
|
||||
);
|
||||
const branchList = response.data.branches.map((b: { name: string }) => b.name);
|
||||
setBranches(branchList);
|
||||
const defaultBranch = response.data.default_branch;
|
||||
if (defaultBranch) {
|
||||
setCurrentBranch(defaultBranch);
|
||||
}
|
||||
} catch {
|
||||
setBranches([]);
|
||||
}
|
||||
}, [projectId, selectedRepoId]);
|
||||
|
||||
const loadGitStatus = useCallback(async () => {
|
||||
if (!projectId || !selectedRepoId) return;
|
||||
try {
|
||||
const data = await getRepositoryStatus(projectId, selectedRepoId);
|
||||
setGitStatus(data);
|
||||
} catch {
|
||||
setGitStatus(null);
|
||||
}
|
||||
}, [projectId, selectedRepoId]);
|
||||
|
||||
const loadToolTypes = useCallback(async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch {
|
||||
setToolTypes([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProject();
|
||||
void loadRepositories();
|
||||
void loadToolTypes();
|
||||
}, [loadProject, loadRepositories, loadToolTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
}, [loadBranches, loadGitStatus]);
|
||||
|
||||
const handleRepoChange = (repoId: string) => {
|
||||
setSelectedRepoId(repoId);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", repoId);
|
||||
newParams.delete("branch");
|
||||
newParams.delete("path");
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
|
||||
|
||||
return (
|
||||
<section className="repo-workspace">
|
||||
{project && (
|
||||
<WorkspaceHeader
|
||||
project={project}
|
||||
currentRepo={selectedRepo || null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<LoadingState message="Loading repositories..." />
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
|
||||
)}
|
||||
|
||||
{status === "empty" && (
|
||||
<div className="card stack">
|
||||
<EmptyState message="No repositories in this project yet." />
|
||||
<Link
|
||||
className="primary-button"
|
||||
to={`/projects/${projectId}/settings/repositories`}
|
||||
>
|
||||
Manage Repositories
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<>
|
||||
{isMobile ? (
|
||||
// Mobile Layout
|
||||
<div className="mobile-workspace">
|
||||
<div className="mobile-workspace-header">
|
||||
<select
|
||||
value={selectedRepoId || ""}
|
||||
onChange={(e) => handleRepoChange(e.target.value)}
|
||||
className="mobile-repo-selector"
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedRepoId && (
|
||||
<select
|
||||
value={currentBranch}
|
||||
onChange={(e) => {
|
||||
const branch = e.target.value;
|
||||
setCurrentBranch(branch);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("branch", branch);
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
className="mobile-branch-selector"
|
||||
>
|
||||
{branches.map((branch) => (
|
||||
<option key={branch} value={branch}>
|
||||
{branch}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mobile-workspace-content">
|
||||
{mobileTab === "files" && selectedRepoId && (
|
||||
<FileBrowser
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
gitStatus={gitStatus}
|
||||
/>
|
||||
)}
|
||||
{mobileTab === "editor" && selectedRepoId && (
|
||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
||||
)}
|
||||
{mobileTab === "git" && selectedRepoId && gitStatus && (
|
||||
<div className="mobile-git-view">
|
||||
<CommitPanel
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
modified={gitStatus.modified}
|
||||
added={gitStatus.added}
|
||||
deleted={gitStatus.deleted}
|
||||
untracked={gitStatus.untracked}
|
||||
onCommit={() => {
|
||||
void loadGitStatus();
|
||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mobileTab === "terminal" && selectedRepoId && (
|
||||
<InstanceList
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
projectName={project?.name}
|
||||
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
||||
toolTypes={toolTypes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mobile-workspace-tabs">
|
||||
<button
|
||||
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
|
||||
onClick={() => setMobileTab("files")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="folder" size="sm" />
|
||||
<span>Files</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
|
||||
onClick={() => setMobileTab("editor")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
<span>Editor</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
|
||||
onClick={() => setMobileTab("git")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="branch" size="sm" />
|
||||
<span>Git</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
|
||||
onClick={() => setMobileTab("terminal")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="terminal" size="sm" />
|
||||
<span>Terminal</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Desktop Layout
|
||||
<>
|
||||
{selectedRepoId && (
|
||||
<GitToolbar
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
currentBranch={currentBranch}
|
||||
branches={branches}
|
||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||
isMirror={Boolean(selectedRepo?.is_mirror)}
|
||||
onBranchChange={(branch) => {
|
||||
setCurrentBranch(branch);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("branch", branch);
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
onRefresh={() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="workspace-layout">
|
||||
<aside className="workspace-sidebar">
|
||||
<div className="sidebar-section">
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepoId || ""}
|
||||
onChange={(e) => handleRepoChange(e.target.value)}
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedRepoId && (
|
||||
<>
|
||||
<FileBrowser
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
gitStatus={gitStatus}
|
||||
/>
|
||||
{gitStatus && (
|
||||
<CommitPanel
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
modified={gitStatus.modified}
|
||||
added={gitStatus.added}
|
||||
deleted={gitStatus.deleted}
|
||||
untracked={gitStatus.untracked}
|
||||
onCommit={() => {
|
||||
void loadGitStatus();
|
||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<InstanceList
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
projectName={project?.name}
|
||||
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
||||
toolTypes={toolTypes}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="workspace-main">
|
||||
{selectedRepoId && (
|
||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// File Browser Component
|
||||
const FileBrowser = ({
|
||||
projectId,
|
||||
repoId,
|
||||
gitStatus,
|
||||
}: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
gitStatus: GitStatus | null;
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const branch = searchParams.get("branch") || "main";
|
||||
const path = searchParams.get("path") || "";
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/files`,
|
||||
{
|
||||
params: {
|
||||
branch,
|
||||
path,
|
||||
},
|
||||
}
|
||||
);
|
||||
setEntries(response.data.entries || []);
|
||||
} catch {
|
||||
setError("Failed to load files");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId, branch, path]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
// Listen for refresh events
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => void loadFiles();
|
||||
window.addEventListener("refresh-file-tree", handleRefresh);
|
||||
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleEntryClick = (entry: FileTreeEntry) => {
|
||||
if (entry.type === "directory") {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("path", entry.path);
|
||||
setSearchParams(newParams);
|
||||
} else {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("file", entry.path);
|
||||
setSearchParams(newParams);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateUp = () => {
|
||||
if (!path) return;
|
||||
const parentPath = path.split("/").slice(0, -1).join("/");
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (parentPath) {
|
||||
newParams.set("path", parentPath);
|
||||
} else {
|
||||
newParams.delete("path");
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const getFileStatus = (filePath: string): string | null => {
|
||||
if (!gitStatus) return null;
|
||||
if (gitStatus.modified.includes(filePath)) return "modified";
|
||||
if (gitStatus.added.includes(filePath)) return "added";
|
||||
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
||||
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading files...</p>;
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{path && (
|
||||
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
|
||||
<Icon name="folder" size="sm" /> ..
|
||||
</button>
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<EmptyState message="No files in this repository yet." />
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||
return (
|
||||
<button
|
||||
key={entry.path}
|
||||
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
|
||||
{fileStatus && (
|
||||
<span className={`file-status-indicator ${fileStatus}`}>
|
||||
{fileStatus === "modified" && "M"}
|
||||
{fileStatus === "added" && "A"}
|
||||
{fileStatus === "deleted" && "D"}
|
||||
{fileStatus === "untracked" && "?"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<WorkspaceLayout
|
||||
projectId={projectId!}
|
||||
project={project}
|
||||
isMobile={isMobile}
|
||||
mobileTab={mobileTab}
|
||||
selectedRepoId={selectedRepoId}
|
||||
selectedRepo={selectedRepo}
|
||||
branches={branches}
|
||||
currentBranch={currentBranch}
|
||||
gitStatus={gitStatus}
|
||||
toolTypes={toolTypes}
|
||||
repositories={repositories}
|
||||
onMobileTabChange={setMobileTab}
|
||||
onRepoChange={handleRepoChange}
|
||||
onBranchChange={handleBranchChange}
|
||||
onRefresh={() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||
|
||||
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||
import {
|
||||
getUserConfig,
|
||||
updateUserConfig,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
type UserConfigUpdate,
|
||||
} from "../api/settings";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
const TABS = [
|
||||
@@ -16,29 +14,7 @@ const TABS = [
|
||||
{ label: "SSH Keys", path: "ssh-keys" },
|
||||
] as const;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
];
|
||||
|
||||
const TOAST_LEVEL_OPTIONS = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "errors", label: "Errors only" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
const MUTE_CATEGORIES = ["instance", "system", "health", "security"];
|
||||
|
||||
type SettingsOutletContext = {
|
||||
config: UserConfig;
|
||||
handleChange: (
|
||||
key: keyof UserConfigUpdate,
|
||||
value: string | string[] | null,
|
||||
) => void;
|
||||
handleSave: () => Promise<void>;
|
||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||
};
|
||||
export { GeneralSettingsTab } from "../components/features/settings/GeneralSettingsTab";
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
@@ -60,7 +36,6 @@ export const SettingsPage = () => {
|
||||
"idle" | "saving" | "saved" | "error"
|
||||
>("idle");
|
||||
|
||||
// Sync loaded config into local editable state
|
||||
useEffect(() => {
|
||||
if (loadedConfig) {
|
||||
setConfig({
|
||||
@@ -160,125 +135,3 @@ export const SettingsPage = () => {
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const GeneralSettingsTab = () => {
|
||||
const { config, handleChange, handleSave, saveStatus } =
|
||||
useOutletContext<SettingsOutletContext>();
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<h2>General</h2>
|
||||
<label className="form-field">
|
||||
Theme
|
||||
<select
|
||||
value={config.theme}
|
||||
onChange={(e) => handleChange("theme", e.target.value)}
|
||||
>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user name
|
||||
<input
|
||||
type="text"
|
||||
value={config.git_user_name ?? ""}
|
||||
onChange={(e) =>
|
||||
handleChange("git_user_name", e.target.value || null)
|
||||
}
|
||||
placeholder="Your git commit name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user email
|
||||
<input
|
||||
type="email"
|
||||
value={config.git_user_email ?? ""}
|
||||
onChange={(e) =>
|
||||
handleChange("git_user_email", e.target.value || null)
|
||||
}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Default editor
|
||||
<input
|
||||
type="text"
|
||||
value={config.default_editor ?? ""}
|
||||
onChange={(e) =>
|
||||
handleChange("default_editor", e.target.value || null)
|
||||
}
|
||||
placeholder="e.g., vscode, vim, cursor"
|
||||
/>
|
||||
</label>
|
||||
<h3>Notifications</h3>
|
||||
<label className="form-field">
|
||||
Toast level
|
||||
<select
|
||||
value={config.notification_toast_level ?? "all"}
|
||||
onChange={(e) =>
|
||||
handleChange("notification_toast_level", e.target.value)
|
||||
}
|
||||
>
|
||||
{TOAST_LEVEL_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset className="form-field">
|
||||
<legend>Mute categories</legend>
|
||||
<div className="stack-sm">
|
||||
{MUTE_CATEGORIES.map((cat) => (
|
||||
<label
|
||||
key={cat}
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(config.notification_mute_categories ?? []).includes(
|
||||
cat,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const current = config.notification_mute_categories ?? [];
|
||||
const next = e.target.checked
|
||||
? [...current, cat]
|
||||
: current.filter((c) => c !== cat);
|
||||
handleChange("notification_mute_categories", next);
|
||||
}}
|
||||
/>
|
||||
{cat}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="settings-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => void handleSave()}
|
||||
type="button"
|
||||
>
|
||||
{saveStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" /> Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" /> Save Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{saveStatus === "saved" && (
|
||||
<span className="success-text">Settings saved!</span>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<span className="error-text">Failed to save</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,92 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh-keys";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import { LoadingState } from "../components/data-states";
|
||||
import { useSSHKeys } from "../hooks/use-ssh-keys";
|
||||
import { SSHKeyCreateForm } from "../components/features/ssh-keys/SSHKeyCreateForm";
|
||||
import { SSHKeyList } from "../components/features/ssh-keys/SSHKeyList";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||
const [signatures, setSignatures] = useState<Record<string, string>>({});
|
||||
const [signing, setSigning] = useState<Record<string, boolean>>({});
|
||||
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
|
||||
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
|
||||
const safeKeys = keys ?? [];
|
||||
|
||||
async function handleGenerate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
setGenerating(true);
|
||||
await createSSHKey({ name: newKeyName.trim() });
|
||||
setNewKeyName("");
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setMutationError("Failed to generate SSH key");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(keyId: string) {
|
||||
if (!confirm("Are you sure you want to delete this SSH key?")) return;
|
||||
|
||||
try {
|
||||
await deleteSSHKey(keyId);
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setMutationError("Failed to delete SSH key");
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
async function handleSign(keyId: string) {
|
||||
const payload = signPayloads[keyId];
|
||||
if (!payload?.trim()) return;
|
||||
|
||||
try {
|
||||
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
||||
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||
setMutationError(null);
|
||||
} catch {
|
||||
setMutationError("Failed to sign payload");
|
||||
} finally {
|
||||
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify(keyId: string) {
|
||||
const payload = verifyPayloads[keyId];
|
||||
const signature = verifySignatures[keyId];
|
||||
if (!payload?.trim() || !signature?.trim()) return;
|
||||
|
||||
try {
|
||||
setVerifying((prev) => ({ ...prev, [keyId]: true }));
|
||||
const result = await verifySignature(keyId, {
|
||||
payload: payload.trim(),
|
||||
signature: signature.trim(),
|
||||
});
|
||||
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||
setMutationError(null);
|
||||
} catch {
|
||||
setMutationError("Failed to verify signature");
|
||||
} finally {
|
||||
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||
}
|
||||
}
|
||||
const {
|
||||
keys,
|
||||
status,
|
||||
loadKeys,
|
||||
newKeyName,
|
||||
setNewKeyName,
|
||||
generating,
|
||||
mutationError,
|
||||
signPayloads,
|
||||
signatures,
|
||||
signing,
|
||||
verifyPayloads,
|
||||
verifySignatures,
|
||||
verifyResults,
|
||||
verifying,
|
||||
handleGenerate,
|
||||
handleDelete,
|
||||
copyToClipboard,
|
||||
handleSign,
|
||||
handleVerify,
|
||||
setSignPayloads,
|
||||
setVerifyPayloads,
|
||||
setVerifySignatures,
|
||||
} = useSSHKeys();
|
||||
|
||||
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
||||
|
||||
@@ -104,174 +47,38 @@ export const SSHKeysPage = () => {
|
||||
|
||||
{mutationError && <div className="error">{mutationError}</div>}
|
||||
|
||||
<form onSubmit={handleGenerate} className="stack">
|
||||
<div className="form-group">
|
||||
<label htmlFor="key-name">Key Name</label>
|
||||
<input
|
||||
id="key-name"
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="e.g., GitHub Work"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="primary-button" disabled={generating}>
|
||||
{generating ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Generate SSH Key
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<SSHKeyCreateForm
|
||||
newKeyName={newKeyName}
|
||||
setNewKeyName={setNewKeyName}
|
||||
generating={generating}
|
||||
onSubmit={handleGenerate}
|
||||
/>
|
||||
|
||||
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
|
||||
|
||||
<div className="keys-list">
|
||||
{safeKeys.length === 0 ? (
|
||||
<EmptyState message="No SSH keys yet. Generate one above." />
|
||||
) : (
|
||||
safeKeys.map((key) => (
|
||||
<div key={key.id} className="key-card">
|
||||
<div className="key-header">
|
||||
<h3>{key.name}</h3>
|
||||
<button
|
||||
onClick={() => handleDelete(key.id)}
|
||||
className="danger-button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div className="key-meta">
|
||||
<span className="muted">
|
||||
Created: {new Date(key.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="key-public">
|
||||
<code>{key.public_key.substring(0, 50)}...</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(key.public_key)}
|
||||
className="secondary-button"
|
||||
>
|
||||
<Icon name="copy" size="sm" />
|
||||
Copy Full Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="key-signing">
|
||||
<h4>Sign Payload</h4>
|
||||
<div className="form-group">
|
||||
<textarea
|
||||
value={signPayloads[key.id] || ""}
|
||||
onChange={(e) =>
|
||||
setSignPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||
}
|
||||
placeholder="Enter payload to sign..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSign(key.id)}
|
||||
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
|
||||
className="primary-button"
|
||||
>
|
||||
{signing[key.id] ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Signing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="edit" size="sm" />
|
||||
Sign
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{signatures[key.id] && (
|
||||
<div className="signature-result">
|
||||
<label>Signature (base64):</label>
|
||||
<code>{signatures[key.id]}</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(signatures[key.id])}
|
||||
className="secondary-button"
|
||||
>
|
||||
<Icon name="copy" size="sm" />
|
||||
Copy Signature
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="key-verification">
|
||||
<h4>Verify Signature</h4>
|
||||
<div className="form-group">
|
||||
<textarea
|
||||
value={verifyPayloads[key.id] || ""}
|
||||
onChange={(e) =>
|
||||
setVerifyPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||
}
|
||||
placeholder="Enter payload..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<textarea
|
||||
value={verifySignatures[key.id] || ""}
|
||||
onChange={(e) =>
|
||||
setVerifySignatures((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||
}
|
||||
placeholder="Enter base64 signature..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleVerify(key.id)}
|
||||
disabled={
|
||||
verifying[key.id] ||
|
||||
!verifyPayloads[key.id]?.trim() ||
|
||||
!verifySignatures[key.id]?.trim()
|
||||
}
|
||||
className="primary-button"
|
||||
>
|
||||
{verifying[key.id] ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="success" size="sm" />
|
||||
Verify
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
|
||||
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
|
||||
{verifyResults[key.id] ? (
|
||||
<>
|
||||
<Icon name="success" size="sm" />
|
||||
Signature is valid
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="error" size="sm" />
|
||||
Signature is invalid
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<SSHKeyList
|
||||
keys={keys}
|
||||
status={status}
|
||||
signPayloads={signPayloads}
|
||||
signatures={signatures}
|
||||
signing={signing}
|
||||
verifyPayloads={verifyPayloads}
|
||||
verifySignatures={verifySignatures}
|
||||
verifyResults={verifyResults}
|
||||
verifying={verifying}
|
||||
onLoadKeys={loadKeys}
|
||||
onDelete={handleDelete}
|
||||
onCopy={copyToClipboard}
|
||||
onSign={handleSign}
|
||||
onVerify={handleVerify}
|
||||
onSignPayloadChange={(id, value) =>
|
||||
setSignPayloads((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
onVerifyPayloadChange={(id, value) =>
|
||||
setVerifyPayloads((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
onVerifySignatureChange={(id, value) =>
|
||||
setVerifySignatures((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
+105
-564
@@ -1,571 +1,112 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { TerminalComponent, type TerminalRef } from "../components/features/terminal/terminal";
|
||||
import {
|
||||
TerminalSessionTabs,
|
||||
type TerminalSessionInfo,
|
||||
} from "../components/features/terminal/terminal-session-tabs";
|
||||
import { Icon } from "../components/icon";
|
||||
import { SpecialKeysStrip } from "../components/features/terminal/special-keys-strip";
|
||||
import { SpecialKeysPanel } from "../components/features/terminal/special-keys-panel";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
||||
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
||||
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
|
||||
import type { TerminalSession } from "../api/terminal";
|
||||
import type { ModifierKey } from "../hooks/use-special-keys";
|
||||
|
||||
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
||||
sessions.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status as TerminalSessionInfo["status"],
|
||||
}));
|
||||
|
||||
type TerminalStatus =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error"
|
||||
| "resetting";
|
||||
import React from "react";
|
||||
import { useTerminalPage } from "../hooks/use-terminal-page";
|
||||
import { MobileTerminalView } from "../components/features/terminal/MobileTerminalView";
|
||||
import { DesktopTerminalView } from "../components/features/terminal/DesktopTerminalView";
|
||||
|
||||
export const TerminalPage: React.FC = () => {
|
||||
const { instanceId } = useParams<{
|
||||
instanceId: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMobileViewport();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||
const {
|
||||
instanceId,
|
||||
navigate,
|
||||
isMobile,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
terminalRefs,
|
||||
headerAutoHide,
|
||||
terminalStatuses,
|
||||
showResetConfirm,
|
||||
setShowResetConfirm,
|
||||
showSpecialKeysPanel,
|
||||
setShowSpecialKeysPanel,
|
||||
activeModifier,
|
||||
setActiveModifier,
|
||||
isKeyboardOpen,
|
||||
keyboardHeight,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
loading,
|
||||
error,
|
||||
handleFullscreenClick,
|
||||
handleSelect,
|
||||
handleClose,
|
||||
handleCreate,
|
||||
handleRename,
|
||||
handleTerminalReady,
|
||||
handleFontSizeChange,
|
||||
handleSendKey,
|
||||
handleReset,
|
||||
sessionInfos,
|
||||
} = useTerminalPage();
|
||||
|
||||
// Track terminal status and callbacks for unified fullscreen header
|
||||
const [terminalStatuses, setTerminalStatuses] = useState<
|
||||
Record<string, TerminalStatus>
|
||||
>({});
|
||||
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
||||
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
||||
const focusInputRef = useRef<(() => void) | null>(null);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
|
||||
null,
|
||||
);
|
||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||
useVirtualKeyboard();
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Terminal</h1>
|
||||
<p className="muted">No instance ID provided.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
resetSession,
|
||||
loading,
|
||||
error,
|
||||
} = useTerminalSessions(instanceId ?? "");
|
||||
const status = terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
|
||||
|
||||
// Auto-create default session if none exist after loading completes
|
||||
useEffect(() => {
|
||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||
void createSession("Session 1");
|
||||
}
|
||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileTerminalView
|
||||
instanceId={instanceId}
|
||||
sessions={sessions}
|
||||
sessionInfos={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
terminalRefs={terminalRefs}
|
||||
status={status}
|
||||
error={error}
|
||||
loading={loading}
|
||||
isKeyboardOpen={isKeyboardOpen}
|
||||
keyboardHeight={keyboardHeight}
|
||||
isVisible={headerAutoHide.isVisible}
|
||||
activeModifier={activeModifier}
|
||||
showSpecialKeysPanel={showSpecialKeysPanel}
|
||||
onToggleHeader={headerAutoHide.toggle}
|
||||
onNavigateBack={() => navigate("/sessions")}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
onTerminalReady={handleTerminalReady}
|
||||
onSendKey={handleSendKey}
|
||||
onModifierChange={setActiveModifier}
|
||||
onShowSpecialKeys={() => setShowSpecialKeysPanel(true)}
|
||||
onHideSpecialKeys={() => setShowSpecialKeysPanel(false)}
|
||||
onKeepFocus={() => {
|
||||
/* focus handled by ref */
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure refs map is kept in sync with sessions
|
||||
useEffect(() => {
|
||||
for (const session of sessions) {
|
||||
if (!terminalRefs.current[session.id]) {
|
||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
||||
}
|
||||
}
|
||||
// Clean up refs for closed sessions
|
||||
const currentIds = new Set(sessions.map((s) => s.id));
|
||||
for (const id of Object.keys(terminalRefs.current)) {
|
||||
if (!currentIds.has(id)) {
|
||||
delete terminalRefs.current[id];
|
||||
}
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
// Fit and focus active terminal when switching tabs
|
||||
useEffect(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
const ref = terminalRefs.current[activeSessionId];
|
||||
// Double rAF ensures layout has settled after the display:block switch
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => {
|
||||
ref.current?.fit();
|
||||
ref.current?.focus();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
};
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||
if (!isAltShift) return;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "n":
|
||||
e.preventDefault();
|
||||
if (sessions.length < 5) {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}
|
||||
break;
|
||||
case "w":
|
||||
e.preventDefault();
|
||||
if (
|
||||
activeSessionId &&
|
||||
window.confirm("Close this terminal session?")
|
||||
) {
|
||||
void closeSession(activeSessionId);
|
||||
}
|
||||
break;
|
||||
case "arrowleft":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx > 0) {
|
||||
setActiveSessionId(sessions[idx - 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "arrowright":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx < sessions.length - 1) {
|
||||
setActiveSessionId(sessions[idx + 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "r":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
void resetSession(activeSessionId);
|
||||
}
|
||||
break;
|
||||
case "f":
|
||||
e.preventDefault();
|
||||
setIsFullscreen((prev) => !prev);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
sessions,
|
||||
activeSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
resetSession,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
|
||||
// Keep screen awake while terminal is open
|
||||
useEffect(() => {
|
||||
let wakeLock: WakeLockSentinel | null = null;
|
||||
|
||||
const requestWakeLock = async () => {
|
||||
try {
|
||||
if ("wakeLock" in navigator) {
|
||||
wakeLock = await navigator.wakeLock.request("screen");
|
||||
}
|
||||
} catch {
|
||||
// Wake lock may be denied; silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
void requestWakeLock();
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void requestWakeLock();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
wakeLock?.release().catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Lock page scroll on mobile terminal so swipes scroll the terminal buffer,
|
||||
// not the page.
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
document.documentElement.classList.add("terminal-page-open");
|
||||
document.body.classList.add("terminal-page-open");
|
||||
return () => {
|
||||
document.documentElement.classList.remove("terminal-page-open");
|
||||
document.body.classList.remove("terminal-page-open");
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
// Click outside terminal content/header to exit fullscreen
|
||||
const handleFullscreenClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!isFullscreen) return;
|
||||
const target = e.target as Node;
|
||||
const current = e.currentTarget as HTMLElement;
|
||||
const content = current.querySelector(".terminal-page-content");
|
||||
const header = current.querySelector(".terminal-fullscreen-header");
|
||||
if (content?.contains(target) || header?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
setIsFullscreen(false);
|
||||
},
|
||||
[isFullscreen],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(sessionId: string) => {
|
||||
setActiveSessionId(sessionId);
|
||||
},
|
||||
[setActiveSessionId],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
async (sessionId: string) => {
|
||||
await closeSession(sessionId);
|
||||
},
|
||||
[closeSession],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}, [createSession, sessions.length]);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(sessionId: string, newName: string) => {
|
||||
void renameSession(sessionId, newName);
|
||||
},
|
||||
[renameSession],
|
||||
);
|
||||
|
||||
const handleTerminalReady = useCallback(
|
||||
(
|
||||
sendData: (data: string) => void,
|
||||
status: TerminalStatus,
|
||||
focusInput: () => void,
|
||||
changeFontSize: (delta: number) => void,
|
||||
) => {
|
||||
setTerminalStatuses((prev) => ({
|
||||
...prev,
|
||||
[activeSessionId ?? "default"]: status,
|
||||
}));
|
||||
sendDataRef.current = sendData;
|
||||
focusInputRef.current = focusInput;
|
||||
changeFontSizeRef.current = changeFontSize;
|
||||
},
|
||||
[activeSessionId],
|
||||
);
|
||||
|
||||
const handleFontSizeChange = useCallback((delta: number) => {
|
||||
changeFontSizeRef.current?.(delta);
|
||||
}, []);
|
||||
|
||||
const handleSendKey = useCallback((data: string) => {
|
||||
sendDataRef.current?.(data);
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
terminalRefs.current[activeSessionId].current?.reset();
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Terminal</h1>
|
||||
<p className="muted">No instance ID provided.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionInfos = SESSIONS_TO_INFO(sessions);
|
||||
|
||||
if (isMobile) {
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
||||
const status =
|
||||
terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
|
||||
>
|
||||
{/* Overlay status bar — floats over terminal, never resizes it */}
|
||||
<div
|
||||
className={`mobile-terminal-overlay ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mobile-terminal-toolbar">
|
||||
<div className="mobile-terminal-toolbar-left">
|
||||
<button
|
||||
className="mobile-terminal-toolbtn"
|
||||
onClick={() => navigate("/sessions")}
|
||||
type="button"
|
||||
aria-label="Back"
|
||||
>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mobile-terminal-toolbar-center">
|
||||
<span className="mobile-terminal-title">
|
||||
{activeSession?.name || "Terminal"}
|
||||
</span>
|
||||
<span
|
||||
className={`mobile-terminal-status status-dot ${status}`}
|
||||
aria-label={`Connection status: ${status}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mobile-terminal-toolbar-right">
|
||||
<button
|
||||
className="mobile-terminal-toolbtn"
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
type="button"
|
||||
aria-label="Decrease font size"
|
||||
>
|
||||
<span style={{ fontSize: "0.75rem" }}>A-</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-terminal-toolbtn"
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
type="button"
|
||||
aria-label="Increase font size"
|
||||
>
|
||||
<span style={{ fontSize: "1rem" }}>A+</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-terminal-toolbtn"
|
||||
onClick={() => navigate("/sessions")}
|
||||
type="button"
|
||||
aria-label="Exit terminal"
|
||||
>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mobile-terminal-overlay-tabs">
|
||||
<TerminalSessionTabs
|
||||
sessions={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
isMobile={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content — always fills full viewport */}
|
||||
<div
|
||||
className="terminal-page-content mobile-full"
|
||||
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
|
||||
onClick={() => headerAutoHide.toggle()}
|
||||
>
|
||||
{error && <div className="terminal-error-banner">{error}</div>}
|
||||
{sessions
|
||||
.filter((session) => session.id === activeSessionId)
|
||||
.map((session) => (
|
||||
<div key={session.id} className="terminal-instance active">
|
||||
<TerminalComponent
|
||||
ref={terminalRefs.current[session.id]}
|
||||
instanceId={instanceId}
|
||||
sessionId={session.id}
|
||||
onClose={() => handleClose(session.id)}
|
||||
isMobile={true}
|
||||
showControls={false}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
onTerminalReady={handleTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="terminal-empty-state">
|
||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SpecialKeysStrip
|
||||
onSend={handleSendKey}
|
||||
isVisible={!showSpecialKeysPanel}
|
||||
onMoreClick={() => setShowSpecialKeysPanel(true)}
|
||||
onKeepFocus={() => focusInputRef.current?.()}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
/>
|
||||
|
||||
<SpecialKeysPanel
|
||||
onSend={handleSendKey}
|
||||
isOpen={showSpecialKeysPanel}
|
||||
onClose={() => setShowSpecialKeysPanel(false)}
|
||||
onKeepFocus={() => focusInputRef.current?.()}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
|
||||
onClick={handleFullscreenClick}
|
||||
>
|
||||
{!isFullscreen && (
|
||||
<div className="terminal-page-header">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setIsFullscreen((p) => !p)}
|
||||
type="button"
|
||||
title="Toggle fullscreen (Alt+Shift+F)"
|
||||
>
|
||||
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isFullscreen ? (
|
||||
<div className="terminal-fullscreen-header">
|
||||
<div className="terminal-fullscreen-header-tabs">
|
||||
<TerminalSessionTabs
|
||||
sessions={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
isMobile={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="terminal-fullscreen-header-controls">
|
||||
<span
|
||||
className={`terminal-fullscreen-status status-dot ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
|
||||
aria-label={`Terminal status: ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
|
||||
/>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
type="button"
|
||||
aria-label="Decrease font size"
|
||||
>
|
||||
A-
|
||||
</button>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
type="button"
|
||||
aria-label="Increase font size"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
type="button"
|
||||
aria-label="Reset terminal"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
className="terminal-close"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
type="button"
|
||||
title="Exit fullscreen (Esc)"
|
||||
>
|
||||
Exit
|
||||
</button>
|
||||
</div>
|
||||
{showResetConfirm && (
|
||||
<div className="terminal-reset-confirm">
|
||||
<div className="terminal-reset-confirm-content">
|
||||
<p>
|
||||
Reset terminal? This will kill the current shell session and
|
||||
start fresh.
|
||||
</p>
|
||||
<div className="terminal-reset-confirm-buttons">
|
||||
<button
|
||||
className="terminal-reset-confirm-button cancel"
|
||||
onClick={() => setShowResetConfirm(false)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="terminal-reset-confirm-button confirm"
|
||||
onClick={() => {
|
||||
setShowResetConfirm(false);
|
||||
handleReset();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TerminalSessionTabs
|
||||
sessions={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
isMobile={false}
|
||||
/>
|
||||
)}
|
||||
<div className="terminal-page-content">
|
||||
{error && <div className="terminal-error-banner">{error}</div>}
|
||||
{sessions
|
||||
.filter((session) => session.id === activeSessionId)
|
||||
.map((session) => (
|
||||
<div key={session.id} className="terminal-instance active">
|
||||
<TerminalComponent
|
||||
ref={terminalRefs.current[session.id]}
|
||||
instanceId={instanceId}
|
||||
sessionId={session.id}
|
||||
onClose={() => handleClose(session.id)}
|
||||
isMobile={false}
|
||||
showControls={!isFullscreen}
|
||||
onTerminalReady={handleTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="terminal-empty-state">
|
||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<DesktopTerminalView
|
||||
instanceId={instanceId}
|
||||
sessions={sessions}
|
||||
sessionInfos={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
terminalRefs={terminalRefs}
|
||||
isFullscreen={isFullscreen}
|
||||
status={status}
|
||||
error={error}
|
||||
loading={loading}
|
||||
showResetConfirm={showResetConfirm}
|
||||
onFullscreenClick={handleFullscreenClick}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
onNavigateBack={() => navigate("/sessions")}
|
||||
onToggleFullscreen={() => setIsFullscreen((p) => !p)}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onShowResetConfirm={() => setShowResetConfirm(true)}
|
||||
onHideResetConfirm={() => setShowResetConfirm(false)}
|
||||
onReset={handleReset}
|
||||
onTerminalReady={handleTerminalReady}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
/* Git History Page Styles */
|
||||
.history-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.branch-selector {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.history-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.commit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.commit-list.with-detail {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.commit-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.commit-item:hover {
|
||||
background: #ece7df;
|
||||
}
|
||||
|
||||
.commit-item.selected {
|
||||
border-color: var(--brand);
|
||||
background: #f0f7f4;
|
||||
}
|
||||
|
||||
.commit-graph {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--brand);
|
||||
white-space: pre;
|
||||
flex-shrink: 0;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.graph-line {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.commit-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.commit-header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.commit-hash {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--brand);
|
||||
background: #f0f7f4;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.commit-refs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ref-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
margin: 0 0 0.35rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.commit-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.commit-detail-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.commit-hash-full {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--brand);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.commit-message-full {
|
||||
margin: 0.5rem 0 0;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: #f5f3ee;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.stat.additions {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.stat.deletions {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.stat.additions .stat-value {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.stat.deletions .stat-value {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.parent-list {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.parent-hash {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: #f5f3ee;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
background: #f5f3ee;
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.history-container {
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.commit-list.with-detail {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.commit-detail-panel {
|
||||
grid-column: 2;
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/* ─── Projects Page Refresh ─── */
|
||||
|
||||
.project-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
border-radius: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-toggle:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.project-toggle h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.repo-count {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.project-detail {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.repo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.repo-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.repo-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.repo-header h4 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.workspace-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.workspace-chip a {
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.workspace-chip .ws-branch {
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.workspace-chip .ws-instances {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.workspace-chip .ws-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-chip .ws-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.workspace-chip .ws-actions button:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.workspace-chip .ws-actions button.danger-text:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
/* Repository Workspace */
|
||||
.repo-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.workspace-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.workspace-header-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.workspace-header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.workspace-header-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workspace-header-subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.workspace-header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.workspace-header-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.workspace-header-action-btn:hover {
|
||||
background: var(--bg);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.workspace-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.workspace-title h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.repo-name {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.workspace-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.workspace-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace-sidebar {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
max-height: 40vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.workspace-header-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-section label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workspace-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* File Tree */
|
||||
.file-tree {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.tree-entry {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tree-entry:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.tree-directory {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-up {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* File Viewer */
|
||||
.file-viewer {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-viewer-header {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.file-breadcrumbs {
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--muted);
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.file-content pre {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.file-viewer-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* File Status Indicators */
|
||||
.file-status-indicator {
|
||||
float: right;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.file-status-indicator.modified {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.file-status-indicator.added {
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.file-status-indicator.deleted {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.file-status-indicator.untracked {
|
||||
color: #6b7280;
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
}
|
||||
|
||||
/* Commit Panel */
|
||||
.commit-panel {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.commit-panel h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.file-status {
|
||||
font-weight: bold;
|
||||
font-size: 0.75rem;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-item.modified .file-status {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.file-item.added .file-status {
|
||||
color: #10b981;
|
||||
}
|
||||
.file-item.deleted .file-status {
|
||||
color: #ef4444;
|
||||
}
|
||||
.file-item.untracked .file-status {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.commit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.commit-message-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.commit-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.commit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.commit-error {
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Merge Dialog */
|
||||
.merge-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.merge-form .form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.merge-form label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.merge-form select,
|
||||
.merge-form input,
|
||||
.merge-form textarea {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.merge-form textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.input-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: #10b981;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Git Toolbar - Top Bar Styles */
|
||||
.git-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-button:hover:not(:disabled) {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.toolbar-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar-button.primary {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.branch-select {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.toolbar-error {
|
||||
color: #ef4444;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.new-branch-form {
|
||||
padding: 0.75rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.status-summary {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status-badge.modified {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.status-badge.added {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.status-badge.deleted {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.status-badge.untracked {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* File Editor */
|
||||
.file-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-editor-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-editor-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Git Mount Editor Styles */
|
||||
.git-mount-editor {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.git-mount-editor .section-subtitle {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.git-mount-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.git-mount-item {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.git-mount-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.git-mount-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-mount-repo {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-mount-paths {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.git-mount-branch {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.git-mount-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-mount-add {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.git-mount-add h5 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-mount-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-mount-form .form-row input,
|
||||
.git-mount-form .form-row select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row input.error,
|
||||
.git-mount-form .form-row select.error {
|
||||
border-color: #cd3131;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row .hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-mount-form .form-row .error-text {
|
||||
font-size: 0.75rem;
|
||||
color: #cd3131;
|
||||
}
|
||||
|
||||
.git-mount-form .form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.new-repo-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.new-repo-form input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.new-repo-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Mobile Sessions Page */
|
||||
@media (max-width: 767px) {
|
||||
.sessions-page {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.sessions-page .page-header {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.sessions-page .page-header h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.last-session-section {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.last-session-section h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.create-session-section {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.create-session-section h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.session-card-actions.mobile {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.session-card-actions.mobile .mobile-primary {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.session-card-actions.mobile .mobile-more {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.create-session-form .form-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.create-session-form input,
|
||||
.create-session-form select,
|
||||
.create-session-form textarea,
|
||||
.create-session-form button {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/* SSH keys table responsive */
|
||||
.ssh-key-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ssh-key-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.ssh-key-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
/* ─── Workspace Detail Page ─── */
|
||||
|
||||
.workspace-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-header-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.workspace-breadcrumb .sep {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.workspace-breadcrumb strong {
|
||||
color: var(--ink);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.branch-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--brand);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
/* Mobile Tab Bar */
|
||||
.mobile-tab-bar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
justify-content: space-around;
|
||||
padding: var(--space-2) 0;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--border);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.mobile-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-tab.active {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* Workspace Content */
|
||||
.workspace-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Files Tab */
|
||||
.files-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.git-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.git-toolbar-status {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.git-toolbar-status span {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: 6px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-modified {
|
||||
background: var(--warning-light);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-added {
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-deleted {
|
||||
background: var(--danger-light);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-untracked {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.git-toolbar-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.git-toolbar-actions input {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.files-split {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: var(--space-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.tree-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-entry:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.tree-entry.selected {
|
||||
background: var(--brand);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.file-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.file-content {
|
||||
flex: 1;
|
||||
padding: var(--space-4);
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.file-editor {
|
||||
flex: 1;
|
||||
padding: var(--space-3);
|
||||
border: none;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.file-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Git Tab */
|
||||
.git-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.git-tab-header {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.git-tab-header select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.commit-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 120px 120px;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.commit-hash {
|
||||
font-family: monospace;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.commit-author,
|
||||
.commit-date {
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* Tools Tab */
|
||||
.tools-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.empty-state-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-10);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.instances-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.instance-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.instance-card.running {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
/* Settings Tab */
|
||||
.settings-tab {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Mobile Workspace Detail */
|
||||
@media (max-width: 767px) {
|
||||
.workspace-detail.mobile .workspace-content {
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.mobile-tab-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.files-split {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.git-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace Card Link */
|
||||
.workspace-header-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
margin: -1rem -1rem 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.workspace-header-link:hover .workspace-header h4 {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* ─── Workspace Create Inline ─── */
|
||||
|
||||
.workspace-create-inline {
|
||||
padding: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.workspace-create-inline h3 {
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-4);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.workspace-create-form-grid .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid input,
|
||||
.workspace-create-form-grid select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid .form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/* Syntax Highlighter */
|
||||
.syntax-highlighter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.highlighter-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.language-badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--bg);
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.code-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-family: "Fira Code", "Monaco", "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 0.5rem;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
display: block;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Code Editor */
|
||||
.code-editor {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
font-family: "Fira Code", "Monaco", "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.editor-textarea-input {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
caret-color: var(--ink);
|
||||
}
|
||||
|
||||
.editor-line {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-line-number {
|
||||
display: inline-block;
|
||||
width: 3rem;
|
||||
padding: 0 0.5rem;
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
user-select: none;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.editor-line-content {
|
||||
flex: 1;
|
||||
padding: 0 0.5rem;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Prism.js Theme Integration */
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: var(--ink);
|
||||
text-shadow: none;
|
||||
font-family: "Fira Code", "Monaco", "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
tab-size: 2;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
/* Syntax Highlighting Colors */
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.token.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
--bg: #f4f1ea;
|
||||
--panel: #fffef9;
|
||||
--ink: #1d1d1b;
|
||||
--muted: #5f5b55;
|
||||
--brand: #275d4b;
|
||||
--brand-strong: #154236;
|
||||
--border: #d8d0c5;
|
||||
--primary: #275d4b;
|
||||
--primary-fg: #fffef9;
|
||||
--color-primary: #275d4b;
|
||||
--success: #2f8f62;
|
||||
--success-light: rgba(47, 143, 98, 0.14);
|
||||
--warning: #c08a1e;
|
||||
--warning-light: rgba(192, 138, 30, 0.14);
|
||||
--danger: #b94a3c;
|
||||
--danger-light: rgba(185, 74, 60, 0.14);
|
||||
--info: #4f7fb8;
|
||||
--info-light: rgba(79, 127, 184, 0.14);
|
||||
|
||||
/* Spacing Scale (4px base) */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2rem;
|
||||
--space-8: 3rem;
|
||||
--space-10: 4rem;
|
||||
|
||||
/* Breakpoints */
|
||||
--bp-sm: 480px;
|
||||
--bp-md: 768px;
|
||||
--bp-lg: 1024px;
|
||||
--bp-xl: 1280px;
|
||||
|
||||
/* Fluid Typography */
|
||||
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
|
||||
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
|
||||
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
|
||||
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
|
||||
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
|
||||
--font-size-2xl: clamp(1.5rem, 1.3rem + 1vw, 2rem);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #171613;
|
||||
--panel: #22201d;
|
||||
--ink: #ece7df;
|
||||
--muted: #a59d92;
|
||||
--brand: #5fa889;
|
||||
--brand-strong: #4d9175;
|
||||
--border: #39342d;
|
||||
--primary: #5fa889;
|
||||
--primary-fg: #171613;
|
||||
--color-primary: #5fa889;
|
||||
--success: #22c55e;
|
||||
--success-light: rgba(34, 197, 94, 0.15);
|
||||
--warning: #f59e0b;
|
||||
--warning-light: rgba(245, 158, 11, 0.15);
|
||||
--danger: #ef4444;
|
||||
--danger-light: rgba(239, 68, 68, 0.15);
|
||||
--info: #3b82f6;
|
||||
--info-light: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
html.terminal-page-open,
|
||||
body.terminal-page-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-theme="dark"] body {
|
||||
background: radial-gradient(circle at top right, #2a2520, var(--bg));
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user