feat: reorganize long files - frontend pages, CSS, partial backend

Frontend:
- Extract ToolWorkshopPage (1269→110), ConfigProfilesPage (1611→170),
  TerminalPage (571→112), SettingsPage (284→137), SshKeysPage (277→84),
  ProjectsPage (433→113), RepoWorkspacePage (505→89)
- Extract 15+ components and 8 hooks for state management
- Delete monolithic styles.css (5683 lines), extract to styles/ directory

Backend:
- Extract config_profiles helpers to services/config/crud_service.py
  and resolver_service.py (842→474 lines)
- Extract git_repositories helpers to services/git/operations.py
  (1588→1422 lines)

Quality gates: tsc --noEmit pass, npm run build pass, py_compile pass
Tests: 9/12 files pass (3 pre-existing failures)
This commit is contained in:
Developer
2026-06-05 20:19:45 +00:00
45 changed files with 11284 additions and 10921 deletions
+48 -416
View File
@@ -1,10 +1,7 @@
"""Config profile API endpoints."""
import logging
import os
import subprocess
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
@@ -12,10 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ConfigProfile, ConfigProfileInclude
from src.models.project import Project
from src.models import ToolType
from src.models import UserConfig
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
from src.schemas.config import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
@@ -31,121 +25,25 @@ from src.services.config.config_profile_resolver import (
resolve_profile,
resolved_profile_to_dict,
)
from src.utils.git_url_parser import parse_git_url
from src.services.config.crud_service import (
calculate_profile_size,
check_access,
get_or_create_user_config,
get_profile_with_includes,
profile_to_response,
validate_default_profiles,
validate_git_mounts,
MAX_PROFILE_SIZE_BYTES,
)
from src.services.config.resolver_service import (
resolve_default_profile,
validate_git_url,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def _calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data."""
total = 0
for key, value in data.get("env_vars", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for key, value in data.get("runtime_hints", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for mount in data.get("mounts", []):
total += len(str(mount.get("target", "")).encode("utf-8"))
total += len(str(mount.get("mode", "")).encode("utf-8"))
for path, content in mount.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
for path, content in data.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
return total
async def _get_profile_with_includes(
session: AsyncSession, profile_id: uuid.UUID
) -> ConfigProfile | None:
"""Fetch a profile with includes eagerly loaded."""
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile_id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one_or_none()
async def _check_access(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID | None = None,
tool_type_id: uuid.UUID | None = None,
) -> None:
"""Verify user has access to referenced project and tool type."""
if project_id is not None:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
# Add ownership check if needed; for now just verify existence
if tool_type_id is not None:
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
)
async def _validate_git_mounts(
session: AsyncSession,
user_id: uuid.UUID,
git_mounts: list[Any],
project_id: uuid.UUID | None = None,
) -> None:
"""Validate git mount URLs.
Simply checks that remote_url looks like a valid git URL.
Actual clone validation happens at instance startup time.
"""
for mount in git_mounts:
remote_url = mount.get("remote_url")
if not remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing remote_url",
)
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid git URL: {remote_url}",
)
def _profile_to_response(
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
) -> dict:
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"project_id": str(profile.project_id) if profile.project_id else None,
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
"env_vars": profile.env_vars or {},
"runtime_hints": profile.runtime_hints or {},
"mounts": profile.mounts or [],
"git_mounts": profile.git_mounts or [],
"files": profile.files or {},
"is_default": profile.is_default,
"includes": [
{
"id": str(inc.id),
"included_profile_id": str(inc.included_profile_id),
"order_index": inc.order_index,
}
for inc in (includes or profile.includes)
],
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles(
@@ -165,26 +63,21 @@ async def list_config_profiles(
)
if project_id or tool_type_id:
# Compatibility filter: include portable profiles and matching scoped profiles
from sqlalchemy import or_
project_uuid = uuid.UUID(project_id) if project_id else None
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
from sqlalchemy import or_
conditions: list = []
# Portable profiles (no project, no tool)
conditions.append(
(ConfigProfile.project_id.is_(None))
& (ConfigProfile.tool_type_id.is_(None))
)
if project_uuid:
# Profiles matching this project (with or without tool)
conditions.append(ConfigProfile.project_id == project_uuid)
if tool_uuid:
# Profiles matching this tool (with or without project)
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
if project_uuid and tool_uuid:
# Exact match
conditions.append(
(ConfigProfile.project_id == project_uuid)
& (ConfigProfile.tool_type_id == tool_uuid)
@@ -194,7 +87,7 @@ async def list_config_profiles(
result = await session.execute(query)
profiles = result.scalars().all()
return [_profile_to_response(p) for p in profiles]
return [profile_to_response(p) for p in profiles]
@router.post(
@@ -208,7 +101,6 @@ async def create_config_profile(
"""Create a new config profile."""
user_uuid = current_user_id
# Check for duplicate name
existing = await session.execute(
select(ConfigProfile)
.where(
@@ -223,24 +115,21 @@ async def create_config_profile(
detail=f"Profile with name '{data.name}' already exists",
)
# Validate references
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await _check_access(session, user_uuid, project_uuid, tool_uuid)
await check_access(session, user_uuid, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if data.git_mounts:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
]
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
await validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
# Check size
size = _calculate_profile_size(data.model_dump())
size = calculate_profile_size(data.model_dump())
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
detail="Profile size exceeds 10MB limit",
)
profile = ConfigProfile(
@@ -259,7 +148,6 @@ async def create_config_profile(
session.add(profile)
await session.commit()
# Re-fetch with includes to avoid lazy loading issues
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
@@ -268,7 +156,7 @@ async def create_config_profile(
profile = result.scalar_one()
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
return _profile_to_response(profile)
return profile_to_response(profile)
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
@@ -278,7 +166,7 @@ async def get_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Get a config profile by ID."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
@@ -287,7 +175,7 @@ async def get_config_profile(
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
return _profile_to_response(profile)
return profile_to_response(profile)
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
@@ -298,7 +186,7 @@ async def update_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Update a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
@@ -310,7 +198,6 @@ async def update_config_profile(
update_data = data.model_dump(exclude_unset=True)
# Handle name uniqueness
if "name" in update_data:
existing = await session.execute(
select(ConfigProfile).where(
@@ -325,7 +212,6 @@ async def update_config_profile(
detail=f"Profile with name '{update_data['name']}' already exists",
)
# Validate references
project_uuid = (
uuid.UUID(update_data["project_id"])
if "project_id" in update_data and update_data["project_id"]
@@ -336,29 +222,26 @@ async def update_config_profile(
if "tool_type_id" in update_data and update_data["tool_type_id"]
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
await check_access(session, profile.user_id, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["git_mounts"]
]
await _validate_git_mounts(
await validate_git_mounts(
session, profile.user_id, git_mounts_data, project_uuid
)
# Check size
current_data = _profile_to_response(profile)
current_data = profile_to_response(profile)
merged = {**current_data, **update_data}
size = _calculate_profile_size(merged)
size = calculate_profile_size(merged)
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
detail="Profile size exceeds 10MB limit",
)
# Apply updates
for field_name, value in update_data.items():
if field_name in ("project_id", "tool_type_id"):
value = uuid.UUID(value) if value else None
@@ -370,7 +253,6 @@ async def update_config_profile(
await session.commit()
# Re-fetch with includes to avoid lazy loading issues
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
@@ -379,7 +261,7 @@ async def update_config_profile(
profile = result.scalar_one()
logger.debug("Updated config profile %s", profile.id)
return _profile_to_response(profile)
return profile_to_response(profile)
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -389,7 +271,7 @@ async def delete_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Delete a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
@@ -414,7 +296,7 @@ async def update_profile_includes(
session: AsyncSession = Depends(get_db_session),
):
"""Update the ordered includes for a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
@@ -424,7 +306,6 @@ async def update_profile_includes(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
# Validate all included profiles exist and belong to the user
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
for inc_uuid in included_uuids:
inc_profile = await session.get(ConfigProfile, inc_uuid)
@@ -444,10 +325,8 @@ async def update_profile_includes(
detail="Profile cannot include itself",
)
# Check for cycles
cycle = await check_include_cycle(session, profile.id, None)
if cycle is None and included_uuids:
# Check each new include would not create a cycle
for inc_uuid in included_uuids:
cycle = await check_include_cycle(session, profile.id, inc_uuid)
if cycle is not None:
@@ -460,7 +339,6 @@ async def update_profile_includes(
detail=f"Include cycle detected: {cycle_str}",
)
# Remove existing includes
result = await session.execute(
select(ConfigProfileInclude).where(
ConfigProfileInclude.profile_id == profile.id
@@ -470,7 +348,6 @@ async def update_profile_includes(
await session.delete(existing)
await session.flush()
# Add new includes
for order_index, inc_uuid in enumerate(included_uuids):
include = ConfigProfileInclude(
profile_id=profile.id,
@@ -479,10 +356,8 @@ async def update_profile_includes(
)
session.add(include)
await session.flush()
await session.commit()
# Re-fetch profile (includes loaded separately due to SQLite async issue)
result = await session.execute(
select(ConfigProfile).where(ConfigProfile.id == profile.id)
)
@@ -496,7 +371,7 @@ async def update_profile_includes(
direct_includes = inc_result.scalars().all()
logger.debug("Updated includes for config profile %s", profile.id)
return _profile_to_response(profile, list(direct_includes))
return profile_to_response(profile, list(direct_includes))
@router.get("/{profile_id}/preview")
@@ -506,7 +381,7 @@ async def preview_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Preview the resolved output of a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
@@ -528,120 +403,19 @@ async def preview_config_profile(
@router.get("/defaults/resolve")
async def resolve_default_profile(
async def resolve_default_profile_endpoint(
project_id: str = Query(..., description="Project ID"),
tool_type_id: str = Query(..., description="Tool type ID"),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Resolve the default config profile for a project/tool combination.
Selects by specificity:
1. project+tool explicit default
2. project explicit default
3. tool explicit default
4. global/user explicit default
5. first created compatible profile
6. none (returns null)
"""
user_uuid = current_user_id
project_uuid = uuid.UUID(project_id)
tool_uuid = uuid.UUID(tool_type_id)
# Fetch all compatible profiles ordered by created_at
query = (
select(ConfigProfile)
.where(ConfigProfile.user_id == user_uuid)
.where(
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
| (ConfigProfile.project_id == project_uuid)
| (ConfigProfile.tool_type_id == tool_uuid)
| (
(ConfigProfile.project_id == project_uuid)
& (ConfigProfile.tool_type_id == tool_uuid)
)
)
.order_by(ConfigProfile.created_at)
"""Resolve the default config profile for a project/tool combination."""
return await resolve_default_profile(
session,
current_user_id,
uuid.UUID(project_id),
uuid.UUID(tool_type_id),
)
result = await session.execute(query)
profiles = result.scalars().all()
if not profiles:
return {"profile_id": None, "profile_name": None}
# Check explicit defaults by specificity
explicit_defaults = [p for p in profiles if p.is_default]
# Most specific: project+tool
for p in explicit_defaults:
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: project only
for p in explicit_defaults:
if p.project_id == project_uuid and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: tool only
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id == tool_uuid:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: global/user (no project, no tool)
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
# Fall back to first created compatible profile
first = profiles[0]
return {"profile_id": str(first.id), "profile_name": first.name}
# ---------------------------------------------------------------------------
# Default profile management
# ---------------------------------------------------------------------------
async def _get_or_create_user_config(
session: AsyncSession,
user_id: uuid.UUID,
) -> UserConfig:
"""Get existing user config or create a new one."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
return user_config
async def _validate_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> None:
"""Validate that all profile IDs in default_profiles belong to the user."""
for tool_type_id, profile_id_str in default_profiles.items():
try:
profile_uuid = uuid.UUID(profile_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
)
profile = await session.get(ConfigProfile, profile_uuid)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Profile not found: {profile_id_str}",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Profile does not belong to user: {profile_id_str}",
)
@router.get("/defaults")
@@ -664,8 +438,8 @@ async def set_default_profiles_endpoint(
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Set default profile mappings for the current user."""
await _validate_default_profiles(session, user_id, data.default_profiles)
user_config = await _get_or_create_user_config(session, user_id)
await validate_default_profiles(session, user_id, data.default_profiles)
user_config = await get_or_create_user_config(session, user_id)
user_config.config = {
**user_config.config,
"default_profiles": data.default_profiles,
@@ -691,152 +465,10 @@ async def get_default_profile_for_tool_type_endpoint(
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
async def validate_git_url(
async def validate_git_url_endpoint(
data: ValidateGitUrlRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ValidateGitUrlResponse:
"""Validate a git remote URL and list available branches.
Parses the URL, suggests corrections for browser URLs, and runs
git ls-remote to verify reachability and enumerate branches.
"""
parse_result = parse_git_url(data.url)
original_url = data.url.strip()
url_to_check = parse_result.get("base_url") or original_url
if not url_to_check:
return ValidateGitUrlResponse(
valid=False,
error=parse_result.get("message", "Invalid URL"),
error_code=parse_result.get("error_code", "INVALID_URL"),
)
# If the URL needed parsing, return suggestion without checking remote
if parse_result.get("needs_parsing") and url_to_check != original_url:
return ValidateGitUrlResponse(
valid=False,
suggested_url=url_to_check,
error=parse_result.get("message"),
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
)
# Optional SSH key for private repos
env = None
key_path = None
if data.ssh_key_id:
from src.models import SSHKey
from src.services.shared.ssh_keys import _get_fernet
try:
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
except ValueError:
return ValidateGitUrlResponse(
valid=False,
error="Invalid SSH key ID format",
error_code="INVALID_SSH_KEY",
)
ssh_key = await session.get(SSHKey, ssh_key_uuid)
if ssh_key is None or ssh_key.user_id != current_user_id:
return ValidateGitUrlResponse(
valid=False,
error="SSH key not found or not authorized",
error_code="SSH_KEY_NOT_FOUND",
)
import tempfile
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
try:
result = subprocess.run(
["git", "ls-remote", "--heads", url_to_check],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
return ValidateGitUrlResponse(
valid=False,
error="Remote repository check timed out",
error_code="TIMEOUT",
)
except FileNotFoundError:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
return ValidateGitUrlResponse(
valid=False,
error="git command not found on server",
error_code="GIT_NOT_FOUND",
)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
stderr = result.stderr.strip()
if (
"could not resolve" in stderr.lower()
or "unable to access" in stderr.lower()
):
error_msg = "Could not reach repository. Check the URL and network access."
error_code = "UNREACHABLE"
elif (
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
):
error_msg = (
"Authentication failed. Provide an SSH key for private repositories."
)
error_code = "AUTH_FAILED"
else:
error_msg = f"Repository not accessible: {stderr[:200]}"
error_code = "REMOTE_ERROR"
return ValidateGitUrlResponse(
valid=False,
error=error_msg,
error_code=error_code,
)
# Parse branches from ls-remote output
branches: list[str] = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.split()
if len(parts) == 2:
ref = parts[1]
# refs/heads/branch-name
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/") :]
branches.append(branch_name)
if branch_name in ("main", "master"):
default_branch = branch_name
if not branches:
return ValidateGitUrlResponse(
valid=False,
error="No branches found in remote repository",
error_code="NO_BRANCHES",
)
return ValidateGitUrlResponse(
valid=True,
suggested_url=url_to_check if url_to_check != original_url else None,
branches=branches,
default_branch=default_branch,
)
"""Validate a git remote URL and list available branches."""
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
+14 -180
View File
@@ -44,6 +44,13 @@ from src.utils.git_control import (
)
from src.utils.git_history import get_commit_detail, get_commit_history
from src.utils.git_url_parser import parse_git_url
from src.services.git.operations import (
build_provider_clone_url,
clone_working_repository,
get_repo_path,
init_working_repository,
preflight_remote_repository,
)
from src.services.shared.ssh_keys import _get_fernet
router = APIRouter(prefix="/projects", tags=["git-repositories"])
@@ -51,179 +58,6 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
logger = logging.getLogger(__name__)
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
"""Generate the filesystem path for a repository.
Args:
user_id: UUID of the repository owner.
project_id: UUID of the project.
name: Repository name.
Returns:
Absolute path to the repository directory.
"""
base = Settings().repo_base_path or "/data/repos"
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
def _build_provider_clone_url(owner: str, repo: str) -> str:
"""Build the SSH clone URL for the fixed git provider."""
return f"git@git.commumedia.org:{owner}/{repo}.git"
def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
"""Prepare environment variables for git commands with SSH authentication.
Returns a dict of extra env vars, or None if no SSH key provided.
The caller is responsible for cleaning up the temporary key file.
"""
if ssh_key is None:
return None
import tempfile
# Decrypt private key
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
# Write to temp file with restricted permissions
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
# Return env vars and the key path for cleanup
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
return env, key_path
def _preflight_remote_repository(
remote_url: str, ssh_key: SSHKey | None = None
) -> None:
"""Verify a remote repository is reachable before cloning."""
env = None
key_path = None
if ssh_key is not None:
ssh_result = _prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try:
result = subprocess.run(
["git", "ls-remote", remote_url],
capture_output=True,
text=True,
timeout=60,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="remote repository check timed out",
)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
logger.error(
"Preflight check failed for %s: stderr=%s", remote_url, result.stderr
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"repository not found or inaccessible: {result.stderr}",
)
def _clone_working_repository(
remote_url: str, repo_path: str, ssh_key: SSHKey | None = None
) -> None:
env = None
key_path = None
if ssh_key is not None:
ssh_result = _prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try:
result = subprocess.run(
["git", "clone", remote_url, repo_path],
capture_output=True,
text=True,
timeout=300,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out"
)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to clone repository: {result.stderr}",
)
def _init_working_repository(repo_path: str) -> None:
try:
result = subprocess.run(
["git", "init", "-b", "main", repo_path],
capture_output=True,
text=True,
)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
if result.returncode == 0:
return
fallback = subprocess.run(
["git", "init", repo_path],
capture_output=True,
text=True,
)
if fallback.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to initialize repository: {fallback.stderr}",
)
ref_result = subprocess.run(
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
capture_output=True,
text=True,
)
if ref_result.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to set initial branch: {ref_result.stderr}",
)
@router.get(
"/repositories",
response_model=list[GitRepositoryResponse],
@@ -350,7 +184,7 @@ async def create_external_repository(
)
if remote_url:
_preflight_remote_repository(remote_url, ssh_key)
preflight_remote_repository(remote_url, ssh_key)
# Create external repo with no project
repo = GitRepository(
@@ -370,7 +204,7 @@ async def create_external_repository(
if remote_url:
try:
_clone_working_repository(remote_url, repo_path, ssh_key)
clone_working_repository(remote_url, repo_path, ssh_key)
repo.is_mirror = False
except Exception as exc:
await session.rollback()
@@ -541,17 +375,17 @@ async def create_repository(
)
if remote_url:
_preflight_remote_repository(remote_url, ssh_key)
preflight_remote_repository(remote_url, ssh_key)
repo_path = _get_repo_path(user_id, project_id, data.name)
repo_path = get_repo_path(user_id, project_id, data.name)
# Ensure parent directory exists
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
if remote_url:
_clone_working_repository(remote_url, repo_path, ssh_key)
clone_working_repository(remote_url, repo_path, ssh_key)
else:
_init_working_repository(repo_path)
init_working_repository(repo_path)
repo = GitRepository(
name=data.name,
@@ -969,7 +803,7 @@ async def get_repository_branches(
if repo.ssh_key_id:
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
ssh_result = _prepare_ssh_env(ssh_key)
ssh_result = prepare_ssh_env(ssh_key)
env = None
key_path = None
if ssh_result:
@@ -0,0 +1,157 @@
"""Config profile CRUD service functions."""
import uuid
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.models import ConfigProfile, ConfigProfileInclude, ToolType, UserConfig
from src.models.project import Project
MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data."""
total = 0
for key, value in data.get("env_vars", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for key, value in data.get("runtime_hints", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for mount in data.get("mounts", []):
total += len(str(mount.get("target", "")).encode("utf-8"))
total += len(str(mount.get("mode", "")).encode("utf-8"))
for path, content in mount.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
for path, content in data.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
return total
async def get_profile_with_includes(
session: AsyncSession, profile_id: uuid.UUID
) -> ConfigProfile | None:
"""Fetch a profile with includes eagerly loaded."""
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile_id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one_or_none()
async def check_access(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID | None = None,
tool_type_id: uuid.UUID | None = None,
) -> None:
"""Verify user has access to referenced project and tool type."""
if project_id is not None:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
if tool_type_id is not None:
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
)
async def validate_git_mounts(
session: AsyncSession,
user_id: uuid.UUID,
git_mounts: list[Any],
project_id: uuid.UUID | None = None,
) -> None:
"""Validate git mount URLs."""
for mount in git_mounts:
remote_url = mount.get("remote_url")
if not remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing remote_url",
)
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid git URL: {remote_url}",
)
def profile_to_response(
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
) -> dict:
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"project_id": str(profile.project_id) if profile.project_id else None,
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
"env_vars": profile.env_vars or {},
"runtime_hints": profile.runtime_hints or {},
"mounts": profile.mounts or [],
"git_mounts": profile.git_mounts or [],
"files": profile.files or {},
"is_default": profile.is_default,
"includes": [
{
"id": str(inc.id),
"included_profile_id": str(inc.included_profile_id),
"order_index": inc.order_index,
}
for inc in (includes or profile.includes)
],
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
async def get_or_create_user_config(
session: AsyncSession,
user_id: uuid.UUID,
) -> UserConfig:
"""Get existing user config or create a new one."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
return user_config
async def validate_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> None:
"""Validate that all profile IDs in default_profiles belong to the user."""
for tool_type_id, profile_id_str in default_profiles.items():
try:
profile_uuid = uuid.UUID(profile_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
)
profile = await session.get(ConfigProfile, profile_uuid)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Profile not found: {profile_id_str}",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Profile does not belong to user: {profile_id_str}",
)
@@ -0,0 +1,208 @@
"""Config profile resolver service functions."""
import logging
import os
import subprocess
import uuid
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import ConfigProfile, SSHKey, UserConfig
from src.services.shared.ssh_keys import _get_fernet
from src.utils.git_url_parser import parse_git_url
from src.schemas.config import ValidateGitUrlResponse
logger = logging.getLogger(__name__)
async def resolve_default_profile(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID,
tool_type_id: uuid.UUID,
) -> dict:
"""Resolve the default config profile for a project/tool combination."""
query = (
select(ConfigProfile)
.where(ConfigProfile.user_id == user_id)
.where(
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
| (ConfigProfile.project_id == project_id)
| (ConfigProfile.tool_type_id == tool_type_id)
| (
(ConfigProfile.project_id == project_id)
& (ConfigProfile.tool_type_id == tool_type_id)
)
)
.order_by(ConfigProfile.created_at)
)
result = await session.execute(query)
profiles = result.scalars().all()
if not profiles:
return {"profile_id": None, "profile_name": None}
explicit_defaults = [p for p in profiles if p.is_default]
for p in explicit_defaults:
if p.project_id == project_id and p.tool_type_id == tool_type_id:
return {"profile_id": str(p.id), "profile_name": p.name}
for p in explicit_defaults:
if p.project_id == project_id and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id == tool_type_id:
return {"profile_id": str(p.id), "profile_name": p.name}
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
first = profiles[0]
return {"profile_id": str(first.id), "profile_name": first.name}
async def validate_git_url(
session: AsyncSession,
current_user_id: uuid.UUID,
url: str,
ssh_key_id: str | None,
) -> ValidateGitUrlResponse:
"""Validate a git remote URL and list available branches."""
parse_result = parse_git_url(url)
original_url = url.strip()
url_to_check = parse_result.get("base_url") or original_url
if not url_to_check:
return ValidateGitUrlResponse(
valid=False,
error=parse_result.get("message", "Invalid URL"),
error_code=parse_result.get("error_code", "INVALID_URL"),
)
if parse_result.get("needs_parsing") and url_to_check != original_url:
return ValidateGitUrlResponse(
valid=False,
suggested_url=url_to_check,
error=parse_result.get("message"),
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
)
env = None
key_path = None
if ssh_key_id:
try:
ssh_key_uuid = uuid.UUID(ssh_key_id)
except ValueError:
return ValidateGitUrlResponse(
valid=False,
error="Invalid SSH key ID format",
error_code="INVALID_SSH_KEY",
)
ssh_key = await session.get(SSHKey, ssh_key_uuid)
if ssh_key is None or ssh_key.user_id != current_user_id:
return ValidateGitUrlResponse(
valid=False,
error="SSH key not found or not authorized",
error_code="SSH_KEY_NOT_FOUND",
)
import tempfile
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
try:
result = subprocess.run(
["git", "ls-remote", "--heads", url_to_check],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
return ValidateGitUrlResponse(
valid=False,
error="Remote repository check timed out",
error_code="TIMEOUT",
)
except FileNotFoundError:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
return ValidateGitUrlResponse(
valid=False,
error="git command not found on server",
error_code="GIT_NOT_FOUND",
)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
stderr = result.stderr.strip()
if (
"could not resolve" in stderr.lower()
or "unable to access" in stderr.lower()
):
error_msg = "Could not reach repository. Check the URL and network access."
error_code = "UNREACHABLE"
elif (
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
):
error_msg = (
"Authentication failed. Provide an SSH key for private repositories."
)
error_code = "AUTH_FAILED"
else:
error_msg = f"Repository not accessible: {stderr[:200]}"
error_code = "REMOTE_ERROR"
return ValidateGitUrlResponse(
valid=False,
error=error_msg,
error_code=error_code,
)
branches: list[str] = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.split()
if len(parts) == 2:
ref = parts[1]
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/") :]
branches.append(branch_name)
if branch_name in ("main", "master"):
default_branch = branch_name
if not branches:
return ValidateGitUrlResponse(
valid=False,
error="No branches found in remote repository",
error_code="NO_BRANCHES",
)
return ValidateGitUrlResponse(
valid=True,
suggested_url=url_to_check if url_to_check != original_url else None,
branches=branches,
default_branch=default_branch,
)
+169
View File
@@ -0,0 +1,169 @@
"""Git repository operations service."""
import logging
import os
import subprocess
import uuid
from fastapi import HTTPException, status
from src.config import Settings
from src.models import SSHKey
from src.services.shared.ssh_keys import _get_fernet
logger = logging.getLogger(__name__)
def get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
"""Generate the filesystem path for a repository."""
base = Settings().repo_base_path or "/data/repos"
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
def build_provider_clone_url(owner: str, repo: str) -> str:
"""Build the SSH clone URL for the fixed git provider."""
return f"git@git.commumedia.org:{owner}/{repo}.git"
def prepare_ssh_env(ssh_key: SSHKey | None) -> tuple[dict, str] | None:
"""Prepare environment variables for git commands with SSH authentication."""
if ssh_key is None:
return None
import tempfile
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
return env, key_path
def preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
"""Verify a remote repository is reachable before cloning."""
env = None
key_path = None
if ssh_key is not None:
ssh_result = prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try:
result = subprocess.run(
["git", "ls-remote", remote_url],
capture_output=True,
text=True,
timeout=60,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="remote repository check timed out",
)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
logger.error(
"Preflight check failed for %s: stderr=%s", remote_url, result.stderr
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"repository not found or inaccessible: {result.stderr}",
)
def clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
"""Clone a remote repository to a local path."""
env = None
key_path = None
if ssh_key is not None:
ssh_result = prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try:
result = subprocess.run(
["git", "clone", remote_url, repo_path],
capture_output=True,
text=True,
timeout=300,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out"
)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to clone repository: {result.stderr}",
)
def init_working_repository(repo_path: str) -> None:
"""Initialize a new git repository at the given path."""
try:
result = subprocess.run(
["git", "init", "-b", "main", repo_path],
capture_output=True,
text=True,
)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
if result.returncode == 0:
return
fallback = subprocess.run(
["git", "init", repo_path],
capture_output=True,
text=True,
)
if fallback.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to initialize repository: {fallback.stderr}",
)
ref_result = subprocess.run(
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
capture_output=True,
text=True,
)
if ref_result.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to set initial branch: {ref_result.stderr}",
)
@@ -0,0 +1,305 @@
import { Icon } from "../../icon";
import { GitMountEditor } from "../git/git-mount-editor";
import type { ConfigProfile, CreateConfigProfileRequest, ResolvedProfile } from "../../../api/config-profiles";
import type { ProjectWithRepos } from "../../../types";
import type { ToolType } from "../../../api/tool-types";
interface Props {
isCreating: boolean;
selectedProfile: ConfigProfile | null;
formData: CreateConfigProfileRequest;
includedProfileIds: string[];
dragOverIndex: number | null;
error: string | null;
saveStatus: "idle" | "saving" | "saved" | "error";
previewData: ResolvedProfile | null;
previewingId: string | null;
projects: ProjectWithRepos[];
toolTypes: ToolType[];
availableProfiles: ConfigProfile[];
getIncludedProfile: (id: string) => ConfigProfile | undefined;
getScopeLabel: (profile: ConfigProfile) => string;
onFormChange: <K extends keyof CreateConfigProfileRequest>(key: K, value: CreateConfigProfileRequest[K]) => void;
onSubmit: (e?: React.FormEvent) => void;
onReset: () => void;
onPreview: () => void;
onAddInclude: (id: string) => void;
onRemoveInclude: (index: number) => void;
onDragStart: (e: React.DragEvent, index: number) => void;
onDragOver: (e: React.DragEvent, index: number) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, index: number) => void;
onAddEnvVar: () => void;
onUpdateEnvVar: (oldKey: string, newKey: string, value: string) => void;
onRemoveEnvVar: (key: string) => void;
onAddFile: () => void;
onUpdateFile: (oldPath: string, newPath: string, content: string) => void;
onRemoveFile: (path: string) => void;
onAddMount: () => void;
onUpdateMount: (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => void;
onRemoveMount: (index: number) => void;
onAddMountFile: (mountIndex: number) => void;
onUpdateMountFile: (mountIndex: number, oldPath: string, newPath: string, content: string) => void;
onRemoveMountFile: (mountIndex: number, path: string) => void;
onClosePreview: () => void;
}
export const ConfigProfileEditorPanel = ({
isCreating,
selectedProfile,
formData,
includedProfileIds,
dragOverIndex,
error,
saveStatus,
previewData,
previewingId,
projects,
toolTypes,
availableProfiles,
getIncludedProfile,
getScopeLabel,
onFormChange,
onSubmit,
onReset,
onPreview,
onAddInclude,
onRemoveInclude,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onAddEnvVar,
onUpdateEnvVar,
onRemoveEnvVar,
onAddFile,
onUpdateFile,
onRemoveFile,
onAddMount,
onUpdateMount,
onRemoveMount,
onAddMountFile,
onUpdateMountFile,
onRemoveMountFile,
onClosePreview,
}: Props) => {
const hasSelection = isCreating || selectedProfile;
return (
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
{!hasSelection ? (
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
<Icon name="folder" size="lg" />
</div>
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a config profile</h3>
<p style={{ margin: 0 }}>Choose a profile from the list to edit, or create a new one.</p>
</div>
) : (
<div>
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
{isCreating ? "Create Profile" : selectedProfile?.name}
</h1>
{!isCreating && selectedProfile && (
<p className="muted" style={{ margin: 0 }}>
{selectedProfile.project_id &&
`Project: ${projects.find((p) => p.id === selectedProfile.project_id)?.name || selectedProfile.project_id}`}
{selectedProfile.project_id && selectedProfile.tool_type_id && " · "}
{selectedProfile.tool_type_id &&
`Tool: ${toolTypes.find((t) => t.id === selectedProfile.tool_type_id)?.display_name || selectedProfile.tool_type_id}`}
</p>
)}
</div>
{!isCreating && selectedProfile && (
<div style={{ display: "flex", gap: "0.5rem" }}>
<button className="secondary-button" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
{previewingId === selectedProfile.id ? (
<><Icon name="loading" size="sm" /> Previewing...</>
) : (
<><Icon name="info" size="sm" /> Preview</>
)}
</button>
</div>
)}
</div>
{error && <div className="error" style={{ marginBottom: "1rem" }}>{error}</div>}
{saveStatus === "saved" && (
<div style={{ marginBottom: "1rem", padding: "0.75rem 1rem", background: "var(--success-bg, #dcfce7)", color: "var(--success, #166534)", borderRadius: "0.375rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Icon name="success" size="sm" />
Profile saved successfully
</div>
)}
<form onSubmit={onSubmit} className="stack" style={{ gap: "1.25rem", maxWidth: "800px" }}>
<div className="form-group">
<label htmlFor="profile-name">Name *</label>
<input id="profile-name" type="text" value={formData.name} onChange={(e) => onFormChange("name", e.target.value)} placeholder="e.g., Development Environment" className="form-input" required />
</div>
<div className="form-group">
<label htmlFor="profile-description">Description</label>
<input id="profile-description" type="text" value={formData.description || ""} onChange={(e) => onFormChange("description", e.target.value || undefined)} placeholder="Optional description" className="form-input" />
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="profile-project">Project</label>
<select id="profile-project" value={formData.project_id || ""} onChange={(e) => onFormChange("project_id", e.target.value || undefined)} className="form-input">
<option value="">None (Global)</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="profile-tool">Tool Type</label>
<select id="profile-tool" value={formData.tool_type_id || ""} onChange={(e) => onFormChange("tool_type_id", e.target.value || undefined)} className="form-input">
<option value="">None</option>
{toolTypes.map((toolType) => (
<option key={toolType.id} value={toolType.id}>{toolType.display_name}</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" checked={formData.is_default || false} onChange={(e) => onFormChange("is_default", e.target.checked)} />
Set as default for this scope
</label>
</div>
<div className="form-section">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
<h4 style={{ margin: 0 }}>Includes</h4>
<span className="muted" style={{ fontSize: "0.875rem" }}>{includedProfileIds.length} included</span>
</div>
{includedProfileIds.length === 0 ? (
<p className="muted" style={{ fontSize: "0.875rem", margin: "0 0 0.75rem 0" }}>No profiles included. Add profiles to compose configurations.</p>
) : (
<div style={{ marginBottom: "0.75rem" }}>
{includedProfileIds.map((profileId, index) => {
const profile = getIncludedProfile(profileId);
if (!profile) return null;
return (
<div
key={`${profileId}-${index}`}
draggable
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, index)}
style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 0.75rem", background: dragOverIndex === index ? "var(--brand-bg, #e0e7ff)" : "var(--panel)", border: "1px solid var(--border)", borderRadius: "0.375rem", marginBottom: "0.25rem", cursor: "grab", transition: "background 0.15s" }}
>
<span style={{ cursor: "grab", color: "var(--muted)" }}><Icon name="drag" size="sm" /></span>
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
<span style={{ fontSize: "0.75rem", padding: "0.125rem 0.375rem", background: "var(--badge-bg, #f3f4f6)", color: "var(--muted)", borderRadius: "0.25rem", textTransform: "uppercase", letterSpacing: "0.025em" }}>{getScopeLabel(profile)}</span>
<button type="button" onClick={() => onRemoveInclude(index)} style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", padding: "0.25rem", borderRadius: "0.25rem" }} title="Remove include"><Icon name="delete" size="sm" /></button>
</div>
);
})}
</div>
)}
{availableProfiles.length > 0 && (
<div className="form-group" style={{ marginBottom: 0 }}>
<select value="" onChange={(e) => { if (e.target.value) { onAddInclude(e.target.value); e.target.value = ""; } }} className="form-input">
<option value="">+ Add Include...</option>
{availableProfiles.map((p) => (
<option key={p.id} value={p.id}>{p.name} ({getScopeLabel(p)})</option>
))}
</select>
</div>
)}
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
<div key={idx} className="form-row" style={{ gap: "0.5rem", marginBottom: "0.5rem" }}>
<input type="text" value={key} onChange={(e) => onUpdateEnvVar(key, e.target.value, value)} placeholder="VAR_NAME" className="form-input" style={{ flex: 1 }} />
<input type="text" value={value} onChange={(e) => onUpdateEnvVar(key, key, e.target.value)} placeholder="value" className="form-input" style={{ flex: 1 }} />
<button type="button" className="ghost-button small" onClick={() => onRemoveEnvVar(key)}><Icon name="delete" size="sm" /></button>
</div>
))}
<button type="button" className="secondary-button" onClick={onAddEnvVar}><Icon name="add" size="sm" /> Add Variable</button>
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Runtime Hints</h4>
<textarea value={JSON.stringify(formData.runtime_hints || {}, null, 2)} onChange={(e) => { try { const parsed = JSON.parse(e.target.value); onFormChange("runtime_hints", parsed); } catch { /* ignore */ } }} placeholder='{"start_command": "npm start"}' rows={4} className="form-input" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.5rem 0" }}>Files</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>Relative paths written to the instance directory. Use Mounts below for absolute container paths.</p>
{Object.entries(formData.files || {}).map(([path, content], idx) => (
<div key={idx} className="card" style={{ padding: "0.75rem", marginBottom: "0.5rem" }}>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input type="text" value={path} onChange={(e) => onUpdateFile(path, e.target.value, content)} placeholder="relative/path/to/file" className="form-input" style={{ flex: 1 }} />
<button type="button" className="ghost-button small" onClick={() => onRemoveFile(path)}><Icon name="delete" size="sm" /></button>
</div>
<textarea value={content} onChange={(e) => onUpdateFile(path, path, e.target.value)} placeholder="File content" rows={3} className="form-input" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
</div>
))}
<button type="button" className="secondary-button" onClick={onAddFile}><Icon name="add" size="sm" /> Add File</button>
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.5rem 0" }}>Mounts</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>Bind directories into the container at absolute paths. Files are relative to the mount target.</p>
{(formData.mounts || []).map((mount, index) => (
<div key={index} className="card" style={{ padding: "1rem", marginBottom: "0.75rem" }}>
<div className="form-row" style={{ gap: "0.5rem", marginBottom: "0.75rem" }}>
<input type="text" value={mount.target} onChange={(e) => onUpdateMount(index, { target: e.target.value })} placeholder="/target/path" className="form-input" style={{ flex: 1 }} />
<select value={mount.mode} onChange={(e) => onUpdateMount(index, { mode: e.target.value as "ro" | "rw" })} className="form-input" style={{ width: "120px" }}>
<option value="rw">Read/Write</option>
<option value="ro">Read-Only</option>
</select>
<button type="button" className="ghost-button small" onClick={() => onRemoveMount(index)}><Icon name="delete" size="sm" /></button>
</div>
<div style={{ marginLeft: "1rem" }}>
{Object.entries(mount.files).map(([path, content], idx) => (
<div key={idx} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input type="text" value={path} onChange={(e) => onUpdateMountFile(index, path, e.target.value, content)} placeholder="relative/path" className="form-input" style={{ flex: 1 }} />
<textarea value={content} onChange={(e) => onUpdateMountFile(index, path, path, e.target.value)} placeholder="File content" rows={2} className="form-input" style={{ flex: 2, fontFamily: "monospace", fontSize: "0.875rem" }} />
<button type="button" className="ghost-button small" onClick={() => onRemoveMountFile(index, path)}><Icon name="delete" size="sm" /></button>
</div>
))}
<button type="button" className="secondary-button small" onClick={() => onAddMountFile(index)} style={{ fontSize: "0.875rem" }}><Icon name="add" size="sm" /> Add File to Mount</button>
</div>
</div>
))}
<button type="button" className="secondary-button" onClick={onAddMount}><Icon name="add" size="sm" /> Add Mount</button>
</div>
<div className="form-section">
<GitMountEditor mounts={formData.git_mounts || []} onChange={(git_mounts) => onFormChange("git_mounts", git_mounts)} />
</div>
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
<button type="submit" disabled={saveStatus === "saving"}>
<Icon name={isCreating ? "add" : "save"} size="sm" />
{saveStatus === "saving" ? "Saving..." : isCreating ? "Create Profile" : "Save Changes"}
</button>
{(isCreating || saveStatus !== "idle") && (
<button type="button" onClick={onReset} className="button-secondary"><Icon name="cancel" size="sm" /> Discard</button>
)}
</div>
</form>
{previewData && (
<div className="card stack" style={{ marginTop: "2rem", padding: "1rem" }}>
<h3>Resolved Profile Preview</h3>
<pre style={{ overflow: "auto", maxHeight: "400px", fontSize: "0.8125rem" }}>{JSON.stringify(previewData, null, 2)}</pre>
<button className="secondary-button" onClick={onClosePreview}>Close Preview</button>
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,174 @@
import { Icon } from "../../icon";
import type { ConfigProfile } from "../../../api/config-profiles";
interface Props {
profiles: ConfigProfile[];
selectedProfileId: string | null;
onSelect: (profile: ConfigProfile) => void;
onCreate: () => void;
onDelete: (id: string) => void;
}
export const ConfigProfileListSidebar = ({
profiles,
selectedProfileId,
onSelect,
onCreate,
onDelete,
}: Props) => {
return (
<div
style={{
width: "280px",
minWidth: "280px",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
background: "var(--panel)",
}}
>
<div style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}>
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Config Profiles</h2>
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
{profiles.length} profile{profiles.length !== 1 ? "s" : ""}
</p>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
{profiles.map((profile) => (
<button
key={profile.id}
onClick={() => onSelect(profile)}
style={{
width: "100%",
textAlign: "left",
padding: "0.75rem 1rem",
marginBottom: "0.25rem",
borderRadius: "0.375rem",
border: "none",
background: selectedProfileId === profile.id ? "var(--brand)" : "transparent",
color: selectedProfileId === profile.id ? "white" : "var(--ink)",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.75rem",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (selectedProfileId !== profile.id) {
e.currentTarget.style.background = "#ece7df";
}
}}
onMouseLeave={(e) => {
if (selectedProfileId !== profile.id) {
e.currentTarget.style.background = "transparent";
}
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.9375rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{profile.name}
{profile.is_default && (
<span
style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.8,
textTransform: "uppercase",
letterSpacing: "0.025em",
}}
>
default
</span>
)}
{profile.includes?.length > 0 && (
<span
style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.7,
background:
selectedProfileId === profile.id
? "rgba(255,255,255,0.2)"
: "var(--badge-bg, #f3f4f6)",
padding: "0.0625rem 0.375rem",
borderRadius: "0.25rem",
}}
>
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
</span>
)}
</div>
<div
style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}
>
{profile.project_id && "Project scoped"}
{profile.tool_type_id && (profile.project_id ? " + Tool scoped" : "Tool scoped")}
{!profile.project_id && !profile.tool_type_id && "Global"}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(profile.id);
}}
style={{
background: "none",
border: "none",
color: selectedProfileId === profile.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
flexShrink: 0,
opacity: 0,
}}
className="delete-btn"
title="Delete profile"
>
<Icon name="delete" size="sm" />
</button>
</button>
))}
</div>
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
<button
onClick={onCreate}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "2px dashed var(--border)",
background: "transparent",
color: "var(--muted)",
cursor: "pointer",
fontWeight: 600,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--brand)";
e.currentTarget.style.color = "var(--brand)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--muted)";
}}
>
<Icon name="add" size="sm" /> New Profile
</button>
</div>
</div>
);
};
@@ -0,0 +1,314 @@
import { MobileListView } from "../mobile/mobile-list-view";
import { MobileDetailView } from "../mobile/mobile-detail-view";
import { MobileEditView } from "../mobile/mobile-edit-view";
import { MobileFAB } from "../mobile/mobile-fab";
import { Icon } from "../../icon";
import type { ConfigProfile, CreateConfigProfileRequest } from "../../../api/config-profiles";
type MobileView = "list" | "detail" | "edit";
interface Props {
profiles: ConfigProfile[];
selectedProfile: ConfigProfile | null;
mobileView: MobileView;
isCreating: boolean;
formData: CreateConfigProfileRequest;
saveStatus: "idle" | "saving" | "saved" | "error";
onViewChange: (view: MobileView) => void;
onSelect: (profile: ConfigProfile) => void;
onCreate: () => void;
onDelete: (id: string) => void;
onFormChange: <K extends keyof CreateConfigProfileRequest>(key: K, value: CreateConfigProfileRequest[K]) => void;
onSubmit: () => void;
getScopeLabel: (profile: ConfigProfile) => string;
}
export const ConfigProfilesMobileView = ({
profiles,
selectedProfile,
mobileView,
isCreating,
formData,
saveStatus,
onViewChange,
onSelect,
onCreate,
onDelete,
onFormChange,
onSubmit,
getScopeLabel,
}: Props) => {
if (mobileView === "list") {
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Config Profiles</h1>
</div>
<MobileListView
items={profiles.map((profile) => ({
id: profile.id,
title: profile.name,
subtitle: profile.description || getScopeLabel(profile),
}))}
onItemClick={(id: string) => {
const profile = profiles.find((p) => p.id === id);
if (profile) {
onSelect(profile);
onViewChange("detail");
}
}}
onItemDelete={(id: string) => onDelete(id)}
emptyMessage="No config profiles yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
}
if (mobileView === "detail" && selectedProfile) {
const fields = [
{ label: "Name", value: selectedProfile.name },
{ label: "Description", value: selectedProfile.description || "-" },
{ label: "Scope", value: getScopeLabel(selectedProfile) },
{ label: "Default", value: selectedProfile.is_default ? "Yes" : "No" },
{
label: "Environment Variables",
value:
Object.keys(selectedProfile.env_vars).length > 0
? Object.entries(selectedProfile.env_vars)
.map(([k, v]) => `${k}=${v}`)
.join(", ")
: "-",
},
{
label: "Mounts",
value:
selectedProfile.mounts.length > 0
? selectedProfile.mounts.map((m) => `${m.target} (${m.mode})`).join(", ")
: "-",
},
{
label: "Includes",
value:
selectedProfile.includes.length > 0
? `${selectedProfile.includes.length} profile(s)`
: "-",
},
];
return (
<MobileDetailView
title={selectedProfile.name}
subtitle={getScopeLabel(selectedProfile)}
fields={fields}
onBack={() => onViewChange("list")}
onEdit={() => {
onSelect(selectedProfile);
onViewChange("edit");
}}
onDelete={() => onDelete(selectedProfile.id)}
/>
);
}
if (mobileView === "edit") {
return (
<MobileEditView
title={isCreating ? "Create Profile" : "Edit Profile"}
onCancel={() => {
onViewChange(isCreating ? "list" : "detail");
}}
onSave={onSubmit}
isSaving={saveStatus === "saving"}
>
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => onFormChange("name", e.target.value)}
placeholder="Profile name"
required
/>
</div>
<div className="form-group">
<label>Description</label>
<textarea
value={formData.description || ""}
onChange={(e) => onFormChange("description", e.target.value || undefined)}
placeholder="Optional description"
rows={3}
/>
</div>
<div className="form-group">
<label>Project</label>
<select
value={formData.project_id || ""}
onChange={(e) => onFormChange("project_id", e.target.value || undefined)}
>
<option value="">Global (all projects)</option>
</select>
</div>
<div className="form-group">
<label>Tool Type</label>
<select
value={formData.tool_type_id || ""}
onChange={(e) => onFormChange("tool_type_id", e.target.value || undefined)}
>
<option value="">Any tool type</option>
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={formData.is_default || false}
onChange={(e) => onFormChange("is_default", e.target.checked)}
/>
Default Profile
</label>
</div>
<div className="form-group">
<label>Environment Variables</label>
{Object.entries(formData.env_vars || {}).map(([key, value], index) => (
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={key}
onChange={(e) => {
const newEnvVars = { ...formData.env_vars };
delete newEnvVars[key];
newEnvVars[e.target.value] = value;
onFormChange("env_vars", newEnvVars);
}}
placeholder="KEY"
style={{ flex: 1 }}
/>
<input
type="text"
value={value}
onChange={(e) => {
const newEnvVars = { ...formData.env_vars };
newEnvVars[key] = e.target.value;
onFormChange("env_vars", newEnvVars);
}}
placeholder="value"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const newEnvVars = { ...formData.env_vars };
delete newEnvVars[key];
onFormChange("env_vars", newEnvVars);
}}
className="secondary-button"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
className="secondary-button"
onClick={() => {
onFormChange("env_vars", { ...formData.env_vars, "": "" });
}}
>
<Icon name="add" size="sm" /> Add Variable
</button>
</div>
<div className="form-group">
<label>Mounts</label>
{(formData.mounts || []).map((mount, index) => (
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={mount.target}
onChange={(e) => {
const newMounts = [...(formData.mounts || [])];
newMounts[index] = { ...mount, target: e.target.value };
onFormChange("mounts", newMounts);
}}
placeholder="Target path"
style={{ flex: 1 }}
/>
<select
value={mount.mode}
onChange={(e) => {
const newMounts = [...(formData.mounts || [])];
newMounts[index] = { ...mount, mode: e.target.value as "ro" | "rw" };
onFormChange("mounts", newMounts);
}}
style={{ width: "80px" }}
>
<option value="ro">Read</option>
<option value="rw">Write</option>
</select>
<button
type="button"
onClick={() => {
const newMounts = (formData.mounts || []).filter((_, i) => i !== index);
onFormChange("mounts", newMounts);
}}
className="secondary-button"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
className="secondary-button"
onClick={() => {
onFormChange("mounts", [...(formData.mounts || []), { target: "/", mode: "rw", files: {} }]);
}}
>
<Icon name="add" size="sm" /> Add Mount
</button>
</div>
</MobileEditView>
);
}
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Config Profiles</h1>
</div>
<MobileListView
items={profiles.map((profile) => ({
id: profile.id,
title: profile.name,
subtitle: profile.description || getScopeLabel(profile),
}))}
onItemClick={(id: string) => {
const profile = profiles.find((p) => p.id === id);
if (profile) {
onSelect(profile);
onViewChange("detail");
}
}}
onItemDelete={(id: string) => onDelete(id)}
emptyMessage="No config profiles yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
};
@@ -0,0 +1,120 @@
import { Icon } from "../../icon";
import { WorkspaceCreateForm } from "../workspace/workspace-create-form";
import type { ProjectWithRepos, WorkspaceSummary } from "../../../types";
interface Props {
project: ProjectWithRepos;
expanded: boolean;
deleteConfirm: boolean;
workspaceLoading: string | null;
showCreateForm: string | null;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onCreateWorkspace: (repoId: string) => void;
onWorkspaceAction: (repoId: string, workspace: WorkspaceSummary, action: "sync" | "delete") => void;
onCancelCreate: () => void;
onCreated: () => void;
}
export const ProjectCard = ({
project,
expanded,
deleteConfirm,
workspaceLoading,
showCreateForm,
onToggle,
onEdit,
onDelete,
onConfirmDelete,
onCancelDelete,
onCreateWorkspace,
onWorkspaceAction,
onCancelCreate,
onCreated,
}: Props) => {
return (
<article className="card project-card">
<div className="project-info-row">
<button className="project-toggle" onClick={onToggle} type="button" aria-expanded={expanded}>
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
<h3>{project.name}</h3>
{project.repositories?.length > 0 && (
<span className="repo-count">
{project.repositories.length} repo{project.repositories.length > 1 ? "s" : ""}
</span>
)}
</button>
<div className="project-actions">
<button className="ghost-button" onClick={onEdit} type="button">
<Icon name="edit" size="sm" /> Edit
</button>
{deleteConfirm ? (
<div className="delete-confirm">
<span>Are you sure?</span>
<button className="danger-button" onClick={onConfirmDelete} type="button">
<Icon name="delete" size="sm" /> Delete
</button>
<button className="ghost-button" onClick={onCancelDelete} type="button">
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
) : (
<button className="ghost-button danger-text" onClick={onDelete} type="button">
<Icon name="delete" size="sm" /> Delete
</button>
)}
</div>
</div>
{expanded && (
<div className="project-detail">
{(project.repositories || []).length === 0 ? (
<p className="muted">No repositories yet.</p>
) : (
<div className="repo-list">
{(project.repositories || []).map((repo) => (
<div key={repo.id} className="repo-block">
<div className="repo-header">
<h4>{repo.name}</h4>
<button className="btn btn-sm btn-primary" onClick={() => onCreateWorkspace(repo.id)} type="button">
<Icon name="add" size="sm" /> New Workspace
</button>
</div>
{showCreateForm === repo.id && (
<WorkspaceCreateForm defaultProjectId={project.id} defaultRepoId={repo.id} onSubmit={onCreated} onCancel={onCancelCreate} />
)}
{repo.workspaces.length === 0 ? (
<p className="muted">No workspaces.</p>
) : (
<div className="workspace-grid">
{repo.workspaces.map((ws) => (
<div key={ws.id} className={`workspace-chip ${ws.status}`}>
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
<span className="ws-branch"><Icon name="branch" size="sm" /> {ws.branch}</span>
{ws.instance_count > 0 && (
<span className="ws-instances">{ws.instance_count} tool{ws.instance_count > 1 ? "s" : ""}</span>
)}
<div className="ws-actions">
<button type="button" disabled={workspaceLoading === ws.id} onClick={() => onWorkspaceAction(repo.id, ws, "sync")}>
<Icon name="refresh" size="sm" />
</button>
<button type="button" className="danger-text" disabled={workspaceLoading === ws.id} onClick={() => onWorkspaceAction(repo.id, ws, "delete")}>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</article>
);
};
@@ -0,0 +1,71 @@
import { Icon } from "../../icon";
interface Props {
mode: "create" | "edit";
name: string;
description: string;
error: string | null;
onNameChange: (name: string) => void;
onDescriptionChange: (desc: string) => void;
onSubmit: (e: React.FormEvent) => void;
onCancel: () => void;
}
export const ProjectDialog = ({
mode,
name,
description,
error,
onNameChange,
onDescriptionChange,
onSubmit,
onCancel,
}: Props) => {
return (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>{mode === "create" ? "Create Project" : "Edit Project"}</h2>
<form onSubmit={onSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="Project name"
/>
</label>
<label className="form-field">
Description
<textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</label>
{error && <p className="error-text">{error}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={onCancel} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{mode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};
@@ -0,0 +1,149 @@
import { useOutletContext } from "react-router-dom";
import { Icon } from "../../icon";
import type { UserConfig, UserConfigUpdate } from "../../../api/settings";
type SettingsOutletContext = {
config: UserConfig;
handleChange: (
key: keyof UserConfigUpdate,
value: string | string[] | null,
) => void;
handleSave: () => Promise<void>;
saveStatus: "idle" | "saving" | "saved" | "error";
};
const THEME_OPTIONS = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
const TOAST_LEVEL_OPTIONS = [
{ value: "all", label: "All" },
{ value: "errors", label: "Errors only" },
{ value: "none", label: "None" },
];
const MUTE_CATEGORIES = ["instance", "system", "health", "security"];
export const GeneralSettingsTab = () => {
const { config, handleChange, handleSave, saveStatus } =
useOutletContext<SettingsOutletContext>();
return (
<div className="stack">
<h2>General</h2>
<label className="form-field">
Theme
<select
value={config.theme}
onChange={(e) => handleChange("theme", e.target.value)}
>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="form-field">
Git user name
<input
type="text"
value={config.git_user_name ?? ""}
onChange={(e) =>
handleChange("git_user_name", e.target.value || null)
}
placeholder="Your git commit name"
/>
</label>
<label className="form-field">
Git user email
<input
type="email"
value={config.git_user_email ?? ""}
onChange={(e) =>
handleChange("git_user_email", e.target.value || null)
}
placeholder="your.email@example.com"
/>
</label>
<label className="form-field">
Default editor
<input
type="text"
value={config.default_editor ?? ""}
onChange={(e) =>
handleChange("default_editor", e.target.value || null)
}
placeholder="e.g., vscode, vim, cursor"
/>
</label>
<h3>Notifications</h3>
<label className="form-field">
Toast level
<select
value={config.notification_toast_level ?? "all"}
onChange={(e) =>
handleChange("notification_toast_level", e.target.value)
}
>
{TOAST_LEVEL_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<fieldset className="form-field">
<legend>Mute categories</legend>
<div className="stack-sm">
{MUTE_CATEGORIES.map((cat) => (
<label
key={cat}
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
<input
type="checkbox"
checked={(config.notification_mute_categories ?? []).includes(
cat,
)}
onChange={(e) => {
const current = config.notification_mute_categories ?? [];
const next = e.target.checked
? [...current, cat]
: current.filter((c) => c !== cat);
handleChange("notification_mute_categories", next);
}}
/>
{cat}
</label>
))}
</div>
</fieldset>
<div className="settings-actions">
<button
className="primary-button"
onClick={() => void handleSave()}
type="button"
>
{saveStatus === "saving" ? (
<>
<Icon name="loading" size="sm" /> Saving...
</>
) : (
<>
<Icon name="save" size="sm" /> Save Settings
</>
)}
</button>
{saveStatus === "saved" && (
<span className="success-text">Settings saved!</span>
)}
{saveStatus === "error" && (
<span className="error-text">Failed to save</span>
)}
</div>
</div>
);
};
@@ -0,0 +1,39 @@
import { Icon } from "../../icon";
interface Props {
newKeyName: string;
setNewKeyName: (name: string) => void;
generating: boolean;
onSubmit: (e: React.FormEvent) => void;
}
export const SSHKeyCreateForm = ({ newKeyName, setNewKeyName, generating, onSubmit }: Props) => {
return (
<form onSubmit={onSubmit} className="stack">
<div className="form-group">
<label htmlFor="key-name">Key Name</label>
<input
id="key-name"
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="e.g., GitHub Work"
required
/>
</div>
<button type="submit" className="primary-button" disabled={generating}>
{generating ? (
<>
<Icon name="loading" size="sm" />
Generating...
</>
) : (
<>
<Icon name="add" size="sm" />
Generate SSH Key
</>
)}
</button>
</form>
);
};
@@ -0,0 +1,180 @@
import { Icon } from "../../icon";
import { EmptyState, ErrorState } from "../../data-states";
import type { SSHKey } from "../../../api/ssh-keys";
interface Props {
keys: SSHKey[];
status: "idle" | "loading" | "ready" | "error";
signPayloads: Record<string, string>;
signatures: Record<string, string>;
signing: Record<string, boolean>;
verifyPayloads: Record<string, string>;
verifySignatures: Record<string, string>;
verifyResults: Record<string, boolean | null>;
verifying: Record<string, boolean>;
onLoadKeys: () => void;
onDelete: (id: string) => void;
onCopy: (text: string) => void;
onSign: (id: string) => void;
onVerify: (id: string) => void;
onSignPayloadChange: (id: string, value: string) => void;
onVerifyPayloadChange: (id: string, value: string) => void;
onVerifySignatureChange: (id: string, value: string) => void;
}
export const SSHKeyList = ({
keys,
status,
signPayloads,
signatures,
signing,
verifyPayloads,
verifySignatures,
verifyResults,
verifying,
onLoadKeys,
onDelete,
onCopy,
onSign,
onVerify,
onSignPayloadChange,
onVerifyPayloadChange,
onVerifySignatureChange,
}: Props) => {
if (status === "error") {
return <ErrorState message="Failed to load SSH keys" onRetry={onLoadKeys} />;
}
if (keys.length === 0) {
return <EmptyState message="No SSH keys yet. Generate one above." />;
}
return (
<div className="keys-list">
{keys.map((key) => (
<div key={key.id} className="key-card">
<div className="key-header">
<h3>{key.name}</h3>
<button onClick={() => onDelete(key.id)} className="danger-button">
<Icon name="delete" size="sm" />
Delete
</button>
</div>
<div className="key-meta">
<span className="muted">
Created: {new Date(key.created_at).toLocaleDateString()}
</span>
</div>
<div className="key-public">
<code>{key.public_key.substring(0, 50)}...</code>
<button
onClick={() => onCopy(key.public_key)}
className="secondary-button"
>
<Icon name="copy" size="sm" />
Copy Full Key
</button>
</div>
<div className="key-signing">
<h4>Sign Payload</h4>
<div className="form-group">
<textarea
value={signPayloads[key.id] || ""}
onChange={(e) => onSignPayloadChange(key.id, e.target.value)}
placeholder="Enter payload to sign..."
rows={3}
/>
</div>
<button
onClick={() => onSign(key.id)}
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
className="primary-button"
>
{signing[key.id] ? (
<>
<Icon name="loading" size="sm" />
Signing...
</>
) : (
<>
<Icon name="edit" size="sm" />
Sign
</>
)}
</button>
{signatures[key.id] && (
<div className="signature-result">
<label>Signature (base64):</label>
<code>{signatures[key.id]}</code>
<button
onClick={() => onCopy(signatures[key.id])}
className="secondary-button"
>
<Icon name="copy" size="sm" />
Copy Signature
</button>
</div>
)}
</div>
<div className="key-verification">
<h4>Verify Signature</h4>
<div className="form-group">
<textarea
value={verifyPayloads[key.id] || ""}
onChange={(e) => onVerifyPayloadChange(key.id, e.target.value)}
placeholder="Enter payload..."
rows={2}
/>
</div>
<div className="form-group">
<textarea
value={verifySignatures[key.id] || ""}
onChange={(e) => onVerifySignatureChange(key.id, e.target.value)}
placeholder="Enter base64 signature..."
rows={2}
/>
</div>
<button
onClick={() => onVerify(key.id)}
disabled={
verifying[key.id] ||
!verifyPayloads[key.id]?.trim() ||
!verifySignatures[key.id]?.trim()
}
className="primary-button"
>
{verifying[key.id] ? (
<>
<Icon name="loading" size="sm" />
Verifying...
</>
) : (
<>
<Icon name="success" size="sm" />
Verify
</>
)}
</button>
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
{verifyResults[key.id] ? (
<>
<Icon name="success" size="sm" />
Signature is valid
</>
) : (
<>
<Icon name="error" size="sm" />
Signature is invalid
</>
)}
</div>
)}
</div>
</div>
))}
</div>
);
};
@@ -0,0 +1,198 @@
import React from "react";
import { TerminalComponent, type TerminalRef } from "./terminal";
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
import type { TerminalSession } from "../../../api/terminal";
interface Props {
instanceId: string;
sessions: TerminalSession[];
sessionInfos: TerminalSessionInfo[];
activeSessionId: string;
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
isFullscreen: boolean;
status: string;
error: string | null;
loading: boolean;
showResetConfirm: boolean;
onFullscreenClick: (e: React.MouseEvent<HTMLElement>) => void;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onCreate: () => void;
onRename: (id: string, name: string) => void;
onNavigateBack: () => void;
onToggleFullscreen: () => void;
onFontSizeChange: (delta: number) => void;
onShowResetConfirm: () => void;
onHideResetConfirm: () => void;
onReset: () => void;
onTerminalReady: (
sendData: (data: string) => void,
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => void;
}
export const DesktopTerminalView: React.FC<Props> = ({
instanceId,
sessions,
sessionInfos,
activeSessionId,
terminalRefs,
isFullscreen,
status,
error,
loading,
showResetConfirm,
onFullscreenClick,
onSelect,
onClose,
onCreate,
onRename,
onNavigateBack,
onToggleFullscreen,
onFontSizeChange,
onShowResetConfirm,
onHideResetConfirm,
onReset,
onTerminalReady,
}) => {
return (
<section
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
onClick={onFullscreenClick}
>
{!isFullscreen && (
<div className="terminal-page-header">
<button className="secondary-button" onClick={onNavigateBack} type="button">
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={onToggleFullscreen}
type="button"
title="Toggle fullscreen (Alt+Shift+F)"
>
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
</button>
</div>
)}
{isFullscreen ? (
<div className="terminal-fullscreen-header">
<div className="terminal-fullscreen-header-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId}
onSelect={onSelect}
onClose={onClose}
onCreate={onCreate}
onRename={onRename}
isMobile={false}
/>
</div>
<div className="terminal-fullscreen-header-controls">
<span
className={`terminal-fullscreen-status status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<button
className="terminal-header-button"
onClick={() => onFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button>
<button
className="terminal-header-button"
onClick={() => onFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={onShowResetConfirm}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
<button
className="terminal-close"
onClick={onToggleFullscreen}
type="button"
title="Exit fullscreen (Esc)"
>
Exit
</button>
</div>
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>
Reset terminal? This will kill the current shell session and
start fresh.
</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={onHideResetConfirm}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
onHideResetConfirm();
onReset();
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
</div>
) : (
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId}
onSelect={onSelect}
onClose={onClose}
onCreate={onCreate}
onRename={onRename}
isMobile={false}
/>
)}
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => onClose(session.id)}
isMobile={false}
showControls={!isFullscreen}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
};
@@ -0,0 +1,161 @@
import React from "react";
import { TerminalComponent, type TerminalRef } from "./terminal";
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
import { Icon } from "../../icon";
import { SpecialKeysStrip } from "./special-keys-strip";
import { SpecialKeysPanel } from "./special-keys-panel";
import type { ModifierKey } from "../../../hooks/use-special-keys";
import type { TerminalSession } from "../../../api/terminal";
interface Props {
instanceId: string;
sessions: TerminalSession[];
sessionInfos: TerminalSessionInfo[];
activeSessionId: string;
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
status: string;
error: string | null;
loading: boolean;
isKeyboardOpen: boolean;
keyboardHeight: number;
isVisible: boolean;
activeModifier: ModifierKey | null;
showSpecialKeysPanel: boolean;
onToggleHeader: () => void;
onNavigateBack: () => void;
onFontSizeChange: (delta: number) => void;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onCreate: () => void;
onRename: (id: string, name: string) => void;
onTerminalReady: (
sendData: (data: string) => void,
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => void;
onSendKey: (data: string) => void;
onModifierChange: (mod: ModifierKey | null) => void;
onShowSpecialKeys: () => void;
onHideSpecialKeys: () => void;
onKeepFocus: () => void;
}
export const MobileTerminalView: React.FC<Props> = ({
instanceId,
sessions,
sessionInfos,
activeSessionId,
terminalRefs,
status,
error,
loading,
isKeyboardOpen,
keyboardHeight,
isVisible,
activeModifier,
showSpecialKeysPanel,
onToggleHeader,
onNavigateBack,
onFontSizeChange,
onSelect,
onClose,
onCreate,
onRename,
onTerminalReady,
onSendKey,
onModifierChange,
onShowSpecialKeys,
onHideSpecialKeys,
onKeepFocus,
}) => {
const activeSession = sessions.find((s) => s.id === activeSessionId);
return (
<section className="terminal-page mobile">
<div className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`} onClick={(e) => e.stopPropagation()}>
<div className="mobile-terminal-toolbar">
<div className="mobile-terminal-toolbar-left">
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Back">
<Icon name="arrow-left" size="sm" />
</button>
</div>
<div className="mobile-terminal-toolbar-center">
<span className="mobile-terminal-title">{activeSession?.name || "Terminal"}</span>
<span className={`mobile-terminal-status status-dot ${status}`} aria-label={`Connection status: ${status}`} />
</div>
<div className="mobile-terminal-toolbar-right">
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(-1)} type="button" aria-label="Decrease font size">
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(1)} type="button" aria-label="Increase font size">
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Exit terminal">
<Icon name="close" size="sm" />
</button>
</div>
</div>
<div className="mobile-terminal-overlay-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId}
onSelect={onSelect}
onClose={onClose}
onCreate={onCreate}
onRename={onRename}
isMobile={true}
/>
</div>
</div>
<div
className="terminal-page-content mobile-full"
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
onClick={onToggleHeader}
>
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => onClose(session.id)}
isMobile={true}
showControls={false}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
<SpecialKeysStrip
onSend={onSendKey}
isVisible={!showSpecialKeysPanel}
onMoreClick={onShowSpecialKeys}
onKeepFocus={onKeepFocus}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
/>
<SpecialKeysPanel
onSend={onSendKey}
isOpen={showSpecialKeysPanel}
onClose={onHideSpecialKeys}
onKeepFocus={onKeepFocus}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
/>
</section>
);
};
@@ -0,0 +1,373 @@
import { Icon } from "../../icon";
import { ManifestEditor } from "../tool/manifest-editor";
import type { ToolType } from "../../../api/tool-types";
import type { ToolDefinitionManifest } from "../../../api/tool-definitions";
export interface ToolTypeFormState {
name: string;
display_name: string;
description: string;
category: string;
interface_type: "web" | "terminal";
requires_port: boolean;
default_port: string;
definition_type: "compose" | "dockerfile" | "manifest";
compose_template: string;
dockerfile_template: string;
readiness_command: string;
readiness_timeout: string;
readiness_interval: string;
required_variables: string;
startup_command: string;
}
interface ToolTypeEditorPanelProps {
isCreating: boolean;
selectedToolType: ToolType | null;
form: ToolTypeFormState;
manifestData: Record<string, unknown> | null;
manifestDefinitionId: string | null;
baseDefinitions: ToolDefinitionManifest[];
toolTypeError: string | null;
toolTypeDirty: boolean;
onFormChange: (changes: Partial<ToolTypeFormState>) => void;
onManifestChange: (manifest: Record<string, unknown> | null) => void;
onSubmit: (e: React.FormEvent) => void;
onReset: () => void;
}
export const ToolTypeEditorPanel = ({
isCreating,
selectedToolType,
form,
manifestData,
manifestDefinitionId,
baseDefinitions,
toolTypeError,
toolTypeDirty,
onFormChange,
onManifestChange,
onSubmit,
onReset,
}: ToolTypeEditorPanelProps) => {
const hasSelection = isCreating || selectedToolType;
return (
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
{!hasSelection ? (
<div
style={{
textAlign: "center",
paddingTop: "4rem",
color: "var(--muted)",
}}
>
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
<Icon name="code" size="lg" />
</div>
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>
Select a tool type
</h3>
<p style={{ margin: 0 }}>
Choose a tool from the list to edit, or create a new one.
</p>
</div>
) : (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
{isCreating
? "Create Tool Type"
: selectedToolType?.display_name}
</h1>
{!isCreating && (
<p className="muted" style={{ margin: 0 }}>
{selectedToolType?.name} · {selectedToolType?.definition_type}{" "}
·{" "}
{selectedToolType?.interface_type === "web"
? `Port ${selectedToolType?.default_port}`
: "Terminal"}
</p>
)}
</div>
<form
onSubmit={onSubmit}
className="stack"
style={{ gap: "1rem", maxWidth: "800px" }}
>
<div className="form-group">
<label htmlFor="definition-type">Definition Type</label>
<select
id="definition-type"
value={form.definition_type}
onChange={(e) => {
onFormChange({
definition_type: e.target.value as
| "compose"
| "dockerfile"
| "manifest",
});
}}
className="form-input"
disabled={!isCreating}
>
<option value="compose">Docker Compose</option>
<option value="dockerfile">Dockerfile</option>
<option value="manifest">Manifest (Declarative)</option>
</select>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-name">Name *</label>
<input
id="tool-type-name"
type="text"
value={form.name}
onChange={(e) => {
onFormChange({ name: e.target.value });
}}
disabled={!isCreating}
placeholder="e.g., code-server"
className="form-input"
required
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-display-name">
Display Name *
</label>
<input
id="tool-type-display-name"
type="text"
value={form.display_name}
onChange={(e) => {
onFormChange({ display_name: e.target.value });
}}
placeholder="e.g., VS Code Server"
className="form-input"
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="tool-type-description">Description</label>
<input
id="tool-type-description"
type="text"
value={form.description}
onChange={(e) => {
onFormChange({ description: e.target.value });
}}
placeholder="Optional description"
className="form-input"
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-category">Category</label>
<input
id="tool-type-category"
type="text"
value={form.category}
onChange={(e) => {
onFormChange({ category: e.target.value });
}}
placeholder="e.g., editor, notebook, ai-assistant"
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-interface">Interface Type</label>
<select
id="tool-type-interface"
value={form.interface_type}
onChange={(e) => {
const value = e.target.value as "web" | "terminal";
onFormChange({
interface_type: value,
requires_port: value === "web",
default_port:
value === "web" ? form.default_port : "",
});
}}
className="form-input"
>
<option value="web">Web</option>
<option value="terminal">Terminal</option>
</select>
</div>
</div>
{form.interface_type === "terminal" && (
<div className="form-group">
<label htmlFor="tool-type-startup-command">
Startup Command
</label>
<input
id="tool-type-startup-command"
type="text"
value={form.startup_command}
onChange={(e) => {
onFormChange({ startup_command: e.target.value });
}}
placeholder="e.g., cd /workspace && ls"
className="form-input"
/>
<small className="form-help">
Command to run before the interactive shell for each new
terminal session.
</small>
</div>
)}
{form.requires_port && (
<div className="form-group">
<label htmlFor="tool-type-default-port">
Default Port *
</label>
<input
id="tool-type-default-port"
type="number"
value={form.default_port}
onChange={(e) => {
onFormChange({ default_port: e.target.value });
}}
placeholder="e.g., 8443"
className="form-input"
required
/>
</div>
)}
{form.definition_type === "manifest" ? (
<ManifestEditor
manifest={manifestData}
baseDefinitions={baseDefinitions}
onChange={(m) => {
onManifestChange(m);
}}
definitionId={manifestDefinitionId}
/>
) : (
<div className="form-group">
<label htmlFor="tool-type-template">
{form.definition_type === "compose"
? "Compose Template"
: "Dockerfile"}{" "}
*
</label>
<textarea
id="tool-type-template"
value={
form.definition_type === "compose"
? form.compose_template
: form.dockerfile_template
}
onChange={(e) => {
if (form.definition_type === "compose") {
onFormChange({ compose_template: e.target.value });
} else {
onFormChange({ dockerfile_template: e.target.value });
}
}}
rows={12}
placeholder={
form.definition_type === "compose"
? "version: '3.8'\nservices:\n app:\n image: ..."
: "FROM node:18\nWORKDIR /app\n..."
}
className="form-input"
style={{
fontFamily: "monospace",
fontSize: "0.875rem",
}}
required
/>
</div>
)}
<div className="form-group">
<label htmlFor="readiness-command">
Readiness Probe Command
</label>
<input
id="readiness-command"
type="text"
value={form.readiness_command}
onChange={(e) => {
onFormChange({ readiness_command: e.target.value });
}}
placeholder="e.g., curl -f http://localhost:8080"
className="form-input"
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
<input
id="readiness-timeout"
type="number"
value={form.readiness_timeout}
onChange={(e) => {
onFormChange({ readiness_timeout: e.target.value });
}}
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="readiness-interval">
Interval (seconds)
</label>
<input
id="readiness-interval"
type="number"
value={form.readiness_interval}
onChange={(e) => {
onFormChange({ readiness_interval: e.target.value });
}}
className="form-input"
/>
</div>
</div>
<div className="form-group">
<label>Required Variables (comma-separated)</label>
<input
type="text"
value={form.required_variables}
onChange={(e) => {
onFormChange({ required_variables: e.target.value });
}}
placeholder="REPO_PATH, TOOL_NAME"
className="form-input"
/>
</div>
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
<div className="dialog-actions" style={{ marginTop: "1rem" }}>
<button type="submit">
<Icon name={isCreating ? "add" : "save"} size="sm" />
{isCreating ? "Create Tool Type" : "Save Changes"}
</button>
{toolTypeDirty && (
<button
type="button"
onClick={onReset}
className="button-secondary"
>
<Icon name="cancel" size="sm" /> Discard
</button>
)}
</div>
</form>
</div>
)}
</div>
);
};
@@ -0,0 +1,161 @@
import { Icon } from "../../icon";
import type { ToolType } from "../../../api/tool-types";
interface ToolTypeListSidebarProps {
toolTypes: ToolType[];
selectedToolTypeId: string | null;
onSelect: (toolType: ToolType) => void;
onCreate: () => void;
onDelete: (id: string) => void;
}
export const ToolTypeListSidebar = ({
toolTypes,
selectedToolTypeId,
onSelect,
onCreate,
onDelete,
}: ToolTypeListSidebarProps) => {
return (
<div
style={{
width: "280px",
minWidth: "280px",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
background: "var(--panel)",
}}
>
<div
style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}
>
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Tool Workshop</h2>
<p
className="muted"
style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}
>
{toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""}
</p>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
{toolTypes.map((toolType) => (
<button
key={toolType.id}
onClick={() => onSelect(toolType)}
style={{
width: "100%",
textAlign: "left",
padding: "0.75rem 1rem",
marginBottom: "0.25rem",
borderRadius: "0.375rem",
border: "none",
background:
selectedToolTypeId === toolType.id
? "var(--brand)"
: "transparent",
color:
selectedToolTypeId === toolType.id ? "white" : "var(--ink)",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.75rem",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (selectedToolTypeId !== toolType.id) {
e.currentTarget.style.background = "#ece7df";
}
}}
onMouseLeave={(e) => {
if (selectedToolTypeId !== toolType.id) {
e.currentTarget.style.background = "transparent";
}
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.9375rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{toolType.display_name}
</div>
<div
style={{
fontSize: "0.8125rem",
opacity: 0.8,
marginTop: "0.125rem",
}}
>
{toolType.category || "Uncategorized"} ·{" "}
{toolType.interface_type === "web"
? `Port ${toolType.default_port}`
: "Terminal"}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(toolType.id);
}}
style={{
background: "none",
border: "none",
color:
selectedToolTypeId === toolType.id
? "rgba(255,255,255,0.8)"
: "var(--muted)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
flexShrink: 0,
opacity: 0,
}}
className="delete-btn"
title="Delete tool type"
>
<Icon name="delete" size="sm" />
</button>
</button>
))}
</div>
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
<button
onClick={onCreate}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "2px dashed var(--border)",
background: "transparent",
color: "var(--muted)",
cursor: "pointer",
fontWeight: 600,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--brand)";
e.currentTarget.style.color = "var(--brand)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--muted)";
}}
>
<Icon name="add" size="sm" /> New Tool Type
</button>
</div>
</div>
);
};
@@ -0,0 +1,350 @@
import { MobileListView } from "../mobile/mobile-list-view";
import { MobileDetailView } from "../mobile/mobile-detail-view";
import { MobileEditView } from "../mobile/mobile-edit-view";
import { MobileFAB } from "../mobile/mobile-fab";
import type { ToolType } from "../../../api/tool-types";
import type { ToolTypeFormState } from "./ToolTypeEditorPanel";
export type MobileView = "list" | "detail" | "edit";
interface ToolWorkshopMobileViewProps {
toolTypes: ToolType[];
selectedToolType: ToolType | null;
mobileView: MobileView;
isCreating: boolean;
toolTypeForm: ToolTypeFormState;
toolTypeError: string | null;
onViewChange: (view: MobileView) => void;
onSelect: (toolType: ToolType) => void;
onCreate: () => void;
onDelete: (id: string) => void;
onFormChange: (changes: Partial<ToolTypeFormState>) => void;
onSubmit: () => void;
onCancel: () => void;
}
export const ToolWorkshopMobileView = ({
toolTypes,
selectedToolType,
mobileView,
isCreating,
toolTypeForm,
toolTypeError,
onViewChange,
onSelect,
onCreate,
onDelete,
onFormChange,
onSubmit,
onCancel,
}: ToolWorkshopMobileViewProps) => {
if (mobileView === "list") {
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Tool Workshop</h1>
<span className="muted">{toolTypes.length} tool types</span>
</div>
<MobileListView
items={toolTypes.map((t) => ({
id: t.id,
title: t.display_name,
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
}))}
onItemClick={(id) => {
const toolType = toolTypes.find((t) => t.id === id);
if (toolType) {
onSelect(toolType);
onViewChange("detail");
}
}}
emptyMessage="No tool types yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
}
if (mobileView === "detail" && selectedToolType) {
return (
<MobileDetailView
title={selectedToolType.display_name}
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type} · ${selectedToolType.interface_type === "web" ? `Port ${selectedToolType.default_port}` : "Terminal"}`}
fields={[
{ label: "Name", value: selectedToolType.name },
{ label: "Display Name", value: selectedToolType.display_name },
{ label: "Description", value: selectedToolType.description },
{ label: "Category", value: selectedToolType.category },
{ label: "Interface Type", value: selectedToolType.interface_type },
{
label: "Requires Port",
value: selectedToolType.requires_port,
type: "boolean",
},
{ label: "Default Port", value: selectedToolType.default_port },
{
label: "Definition Type",
value: selectedToolType.definition_type,
},
{
label: "Startup Command",
value: selectedToolType.startup_command,
},
{
label: "Readiness Command",
value: selectedToolType.readiness_probe?.command ?? null,
},
{
label: "Readiness Timeout",
value: selectedToolType.readiness_probe?.timeout ?? null,
},
{
label: "Readiness Interval",
value: selectedToolType.readiness_probe?.interval ?? null,
},
{
label: "Required Variables",
value: selectedToolType.required_variables?.join(", ") ?? null,
},
{
label: "Compose Template",
value: selectedToolType.compose_template,
type: "code",
},
{
label: "Dockerfile Template",
value: selectedToolType.dockerfile_template,
type: "code",
},
]}
onEdit={() => {
onViewChange("edit");
}}
onDelete={() => {
void onDelete(selectedToolType.id);
onViewChange("list");
}}
onBack={() => {
onViewChange("list");
}}
/>
);
}
if (mobileView === "edit") {
return (
<MobileEditView
title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
onCancel={onCancel}
onSave={() => {
onSubmit();
if (!toolTypeError) {
onViewChange("list");
}
}}
isSaving={false}
>
<div className="mobile-form-group">
<label className="mobile-form-label">Name *</label>
<input
type="text"
value={toolTypeForm.name}
onChange={(e) => onFormChange({ name: e.target.value })}
className="mobile-form-input"
placeholder="e.g., my-tool"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Display Name *</label>
<input
type="text"
value={toolTypeForm.display_name}
onChange={(e) => onFormChange({ display_name: e.target.value })}
className="mobile-form-input"
placeholder="e.g., My Tool"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Description</label>
<textarea
value={toolTypeForm.description}
onChange={(e) => onFormChange({ description: e.target.value })}
className="mobile-form-textarea"
placeholder="What does this tool do?"
rows={3}
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Category</label>
<input
type="text"
value={toolTypeForm.category}
onChange={(e) => onFormChange({ category: e.target.value })}
className="mobile-form-input"
placeholder="e.g., development"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Interface Type</label>
<select
value={toolTypeForm.interface_type}
onChange={(e) =>
onFormChange({
interface_type: e.target.value as "web" | "terminal",
})
}
className="mobile-form-select"
>
<option value="web">Web</option>
<option value="terminal">Terminal</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Requires Port</label>
<input
type="checkbox"
checked={toolTypeForm.requires_port}
onChange={(e) => onFormChange({ requires_port: e.target.checked })}
className="mobile-form-checkbox"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Default Port</label>
<input
type="text"
value={toolTypeForm.default_port}
onChange={(e) => onFormChange({ default_port: e.target.value })}
className="mobile-form-input"
placeholder="e.g., 8080"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Definition Type</label>
<select
value={toolTypeForm.definition_type}
onChange={(e) =>
onFormChange({
definition_type: e.target.value as "compose" | "dockerfile",
})
}
className="mobile-form-select"
>
<option value="compose">Compose</option>
<option value="dockerfile">Dockerfile</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Startup Command</label>
<input
type="text"
value={toolTypeForm.startup_command}
onChange={(e) => onFormChange({ startup_command: e.target.value })}
className="mobile-form-input"
placeholder="Command to run on startup"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Command</label>
<input
type="text"
value={toolTypeForm.readiness_command}
onChange={(e) => onFormChange({ readiness_command: e.target.value })}
className="mobile-form-input"
placeholder="e.g., curl -f http://localhost:8080/health"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Timeout</label>
<input
type="text"
value={toolTypeForm.readiness_timeout}
onChange={(e) => onFormChange({ readiness_timeout: e.target.value })}
className="mobile-form-input"
placeholder="30"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Interval</label>
<input
type="text"
value={toolTypeForm.readiness_interval}
onChange={(e) => onFormChange({ readiness_interval: e.target.value })}
className="mobile-form-input"
placeholder="2"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Required Variables</label>
<input
type="text"
value={toolTypeForm.required_variables}
onChange={(e) => onFormChange({ required_variables: e.target.value })}
className="mobile-form-input"
placeholder="VAR1, VAR2, VAR3"
/>
</div>
{toolTypeForm.definition_type === "compose" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Compose Template</label>
<textarea
value={toolTypeForm.compose_template}
onChange={(e) => onFormChange({ compose_template: e.target.value })}
className="mobile-form-textarea mobile-form-code"
placeholder="version: '3'"
rows={10}
/>
</div>
)}
{toolTypeForm.definition_type === "dockerfile" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Dockerfile Template</label>
<textarea
value={toolTypeForm.dockerfile_template}
onChange={(e) =>
onFormChange({ dockerfile_template: e.target.value })
}
className="mobile-form-textarea mobile-form-code"
placeholder="FROM ubuntu:22.04"
rows={10}
/>
</div>
)}
</MobileEditView>
);
}
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Tool Workshop</h1>
<span className="muted">{toolTypes.length} tool types</span>
</div>
<MobileListView
items={toolTypes.map((t) => ({
id: t.id,
title: t.display_name,
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
}))}
onItemClick={(id) => {
const toolType = toolTypes.find((t) => t.id === id);
if (toolType) {
onSelect(toolType);
onViewChange("detail");
}
}}
emptyMessage="No tool types yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
};
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiClient } from "../../../api/client";
import { EmptyState } from "../../data-states";
import { Icon } from "../../icon";
import type { GitStatus } from "../../../api/git-repositories";
interface FileTreeEntry {
name: string;
type: "file" | "directory";
path: string;
size?: number;
mode?: string;
last_commit?: {
hash: string;
message: string;
author: string;
date: string;
} | null;
}
interface Props {
projectId: string;
repoId: string;
gitStatus: GitStatus | null;
}
export const FileBrowser = ({ projectId, repoId, gitStatus }: Props) => {
const [searchParams, setSearchParams] = useSearchParams();
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const branch = searchParams.get("branch") || "main";
const path = searchParams.get("path") || "";
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files`,
{ params: { branch, path } }
);
setEntries(response.data.entries || []);
} catch {
setError("Failed to load files");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, path]);
useEffect(() => {
void loadFiles();
}, [loadFiles]);
useEffect(() => {
const handleRefresh = () => void loadFiles();
window.addEventListener("refresh-file-tree", handleRefresh);
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
}, [loadFiles]);
const handleEntryClick = (entry: FileTreeEntry) => {
if (entry.type === "directory") {
const newParams = new URLSearchParams(searchParams);
newParams.set("path", entry.path);
setSearchParams(newParams);
} else {
const newParams = new URLSearchParams(searchParams);
newParams.set("file", entry.path);
setSearchParams(newParams);
}
};
const navigateUp = () => {
if (!path) return;
const parentPath = path.split("/").slice(0, -1).join("/");
const newParams = new URLSearchParams(searchParams);
if (parentPath) {
newParams.set("path", parentPath);
} else {
newParams.delete("path");
}
setSearchParams(newParams);
};
const getFileStatus = (filePath: string): string | null => {
if (!gitStatus) return null;
if (gitStatus.modified.includes(filePath)) return "modified";
if (gitStatus.added.includes(filePath)) return "added";
if (gitStatus.deleted.includes(filePath)) return "deleted";
if (gitStatus.untracked.includes(filePath)) return "untracked";
return null;
};
if (loading) return <p className="muted">Loading files...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className="file-tree">
{path && (
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
<Icon name="folder" size="sm" /> ..
</button>
)}
{entries.length === 0 && (
<EmptyState message="No files in this repository yet." />
)}
{entries.map((entry) => {
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
return (
<button
key={entry.path}
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
onClick={() => handleEntryClick(entry)}
type="button"
>
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
{fileStatus && (
<span className={`file-status-indicator ${fileStatus}`}>
{fileStatus === "modified" && "M"}
{fileStatus === "added" && "A"}
{fileStatus === "deleted" && "D"}
{fileStatus === "untracked" && "?"}
</span>
)}
</button>
);
})}
</div>
);
};
@@ -0,0 +1,214 @@
import { Icon } from "../../icon";
import { FileBrowser } from "./FileBrowser";
import { FileEditor } from "../git/file-editor";
import { CommitPanel } from "../git/commit-panel";
import { GitToolbar } from "../git/git-toolbar";
import { InstanceList } from "../tool/instance-list";
import type { GitRepository, GitStatus } from "../../../api/git-repositories";
import type { ToolType } from "../../../api/tool-types";
import type { Project } from "../../../hooks/use-repo-workspace";
type MobileTab = "files" | "editor" | "git" | "terminal";
interface Props {
projectId: string;
project: Project | null;
isMobile: boolean;
mobileTab: MobileTab;
selectedRepoId: string | null;
selectedRepo: GitRepository | undefined;
branches: string[];
currentBranch: string;
gitStatus: GitStatus | null;
toolTypes: ToolType[];
repositories: GitRepository[];
onMobileTabChange: (tab: MobileTab) => void;
onRepoChange: (repoId: string) => void;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
export const WorkspaceLayout = ({
projectId,
project,
isMobile,
mobileTab,
selectedRepoId,
selectedRepo,
branches,
currentBranch,
gitStatus,
toolTypes,
repositories,
onMobileTabChange,
onRepoChange,
onBranchChange,
onRefresh,
}: Props) => {
if (isMobile) {
return (
<div className="mobile-workspace">
<div className="mobile-workspace-header">
<select
value={selectedRepoId || ""}
onChange={(e) => onRepoChange(e.target.value)}
className="mobile-repo-selector"
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
{selectedRepoId && (
<select
value={currentBranch}
onChange={(e) => onBranchChange(e.target.value)}
className="mobile-branch-selector"
>
{branches.map((branch) => (
<option key={branch} value={branch}>
{branch}
</option>
))}
</select>
)}
</div>
<div className="mobile-workspace-content">
{mobileTab === "files" && selectedRepoId && (
<FileBrowser projectId={projectId} repoId={selectedRepoId} gitStatus={gitStatus} />
)}
{mobileTab === "editor" && selectedRepoId && (
<FileEditor projectId={projectId} repoId={selectedRepoId} />
)}
{mobileTab === "git" && selectedRepoId && gitStatus && (
<div className="mobile-git-view">
<CommitPanel
projectId={projectId}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={() => {
onRefresh();
}}
/>
</div>
)}
{mobileTab === "terminal" && selectedRepoId && (
<InstanceList
projectId={projectId}
repoId={selectedRepoId}
projectName={project?.name}
repoName={selectedRepo?.name}
toolTypes={toolTypes}
/>
)}
</div>
<div className="mobile-workspace-tabs">
<button
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
onClick={() => onMobileTabChange("files")}
type="button"
>
<Icon name="folder" size="sm" />
<span>Files</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
onClick={() => onMobileTabChange("editor")}
type="button"
>
<Icon name="edit" size="sm" />
<span>Editor</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
onClick={() => onMobileTabChange("git")}
type="button"
>
<Icon name="branch" size="sm" />
<span>Git</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
onClick={() => onMobileTabChange("terminal")}
type="button"
>
<Icon name="terminal" size="sm" />
<span>Terminal</span>
</button>
</div>
</div>
);
}
return (
<>
{selectedRepoId && (
<GitToolbar
projectId={projectId}
repoId={selectedRepoId}
currentBranch={currentBranch}
branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)}
isMirror={Boolean(selectedRepo?.is_mirror)}
onBranchChange={onBranchChange}
onRefresh={onRefresh}
/>
)}
<div className="workspace-layout">
<aside className="workspace-sidebar">
<div className="sidebar-section">
<label className="form-field">
Repository
<select
value={selectedRepoId || ""}
onChange={(e) => onRepoChange(e.target.value)}
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
</label>
</div>
{selectedRepoId && (
<>
<FileBrowser projectId={projectId} repoId={selectedRepoId} gitStatus={gitStatus} />
{gitStatus && (
<CommitPanel
projectId={projectId}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={onRefresh}
/>
)}
<InstanceList
projectId={projectId}
repoId={selectedRepoId}
projectName={project?.name}
repoName={selectedRepo?.name}
toolTypes={toolTypes}
/>
</>
)}
</aside>
<main className="workspace-main">
{selectedRepoId && (
<FileEditor projectId={projectId} repoId={selectedRepoId} />
)}
</main>
</div>
</>
);
};
+431
View File
@@ -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,
};
};
+155
View File
@@ -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,
};
};
+148
View File
@@ -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,
};
};
+108
View File
@@ -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,
};
};
+289
View File
@@ -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),
};
};
+370
View File
@@ -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
View File
@@ -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
+46 -366
View File
@@ -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>
);
}
+76 -492
View File
@@ -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>
);
};
+2 -149
View File
@@ -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>
);
};
+59 -252
View File
@@ -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
View File
@@ -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
+257
View File
@@ -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;
}
}
+132
View File
@@ -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;
}
+69
View File
@@ -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;
}
}
+25
View File
@@ -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);
}
+195
View File
@@ -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;
}
+92
View File
@@ -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