feat: complete reorganize-long-files cleanup

- Extract tool instance lifecycle endpoints (start/stop/restart/delete) from
  api/tool/tool_instances.py into new api/tool/tool_lifecycle.py.
- Register tool_lifecycle_router in main.py and api/tool/__init__.py.
- Extract inline WorkspaceDetailPage components into
  components/features/workspace/: detail header, tab bars, file/git/tools/settings
  panels. Slim page from ~446 to ~62 lines.
- Update OpenSpec reorganize-long-files tasks to reflect completed work and
  current source state; mark change completed.
- Regenerate project maps.

Quality gates: python3 -m py_compile (backend clean), npm run typecheck,
npm run lint, npm test -- --run (87 passed), pytest workspace integration
and unit tests (27 passed, 1 skipped).
This commit is contained in:
Developer
2026-06-12 18:53:23 +00:00
parent efb62fe41a
commit ce8b5dc86d
45 changed files with 878 additions and 830 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps
## role
Contains the main deployable application modules that compose the complete system.
Contains the main application entry points and executable modules for the project.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps
index: apps/.pi-map.index.md
## role
Contains the main deployable application modules that compose the complete system.
Contains the main application entry points and executable modules for the project.
## files
## arch
Modular application architecture with separate entry points for different application contexts or deployment targets.
Modular application structure with separate deployable units, likely following a microservices or monorepo pattern with distinct apps sharing common libraries.
## tags
-
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api
## role
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
Backend API service that provides a self-hosted FastAPI server for managing development projects, git repositories, and containerized tools via Docker.
## parent
index: apps/.pi-map.index.md
map: apps/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/api
index: apps/api/.pi-map.index.md
## role
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
Backend API service that provides a self-hosted FastAPI server for managing development projects, git repositories, and containerized tools via Docker.
## files
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and avoid copying unnecessary files into containers. | dep: Docker
- Dockerfile | Multi-stage Docker build for a Python application with Docker socket access, Cloudflare tunneling, and database dependency waiting | dep: python:3.11-slim, gcc, libpq-dev, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, pyproject.toml dependencies
@@ -14,7 +14,7 @@ Self-hosted FastAPI backend API that manages projects, git repositories, and dev
- uv.lock | Lock file for the uv Python package manager that pins exact dependency versions and their artifact hashes for reproducible installations | dep: uv, Python 3.11+, aiosqlite, alembic, annotated-doc, annotated-types, anyio, ast-serialize, asyncpg, and many other PyPI packages
- wait-for-db.sh | Wait for a PostgreSQL database to become available before executing a command, with configurable retry logic. | dep: nc (netcat), sh (POSIX shell), sleep
## arch
Multi-stage Dockerized Python application using async FastAPI, PostgreSQL with Alembic migrations, uv package management, and Cloudflare tunneling with external Docker socket access.
Modern Python async architecture using FastAPI with SQLAlchemy/alembic for PostgreSQL, uv for dependency management, multi-stage Docker builds with Cloudflare tunneling, and health-check orchestration for database readiness.
## tags
docker, alembic, python, database, fastapi, postgresql, asyncpg, uvicorn
## symbols
+5 -2
View File
@@ -2,7 +2,7 @@
dir: apps/api/src
## role
Core application package for the Headquarter API backend service, providing configuration, database connectivity, structured logging, and FastAPI application orchestration.
Core application package that bootstraps and configures the Headquarter API FastAPI service with its infrastructure concerns.
## parent
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
@@ -13,6 +13,9 @@ map: apps/api/.pi-map.md
- apps/api/src/auth
index: apps/api/src/auth/.pi-map.index.md
map: apps/api/src/auth/.pi-map.md
- apps/api/src/headquarter_api.egg-info
index: apps/api/src/headquarter_api.egg-info/.pi-map.index.md
map: apps/api/src/headquarter_api.egg-info/.pi-map.md
- apps/api/src/models
index: apps/api/src/models/.pi-map.index.md
map: apps/api/src/models/.pi-map.md
@@ -46,6 +49,6 @@ map: apps/api/src/.pi-map.md
- change src config
read: config.py, logging_config.py
- explore src subdirectories
index: apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md, apps/api/src/models/.pi-map.index.md
index: apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md, apps/api/src/headquarter_api.egg-info/.pi-map.index.md
## dirty
-
+4 -4
View File
@@ -4,15 +4,15 @@ dir: apps/api/src
index: apps/api/src/.pi-map.index.md
## role
Core application package for the Headquarter API backend service, providing configuration, database connectivity, structured logging, and FastAPI application orchestration.
Core application package that bootstraps and configures the Headquarter API FastAPI service with its infrastructure concerns.
## files
- __init__.py | Marks the directory as a Python package for the Headquarter API.
- config.py | Defines application configuration settings using Pydantic with environment variable loading, database URL construction, and computed properties for service URLs and OAuth endpoints. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
- database.py | Configures async SQLAlchemy database engine and session factory, and provides retry logic for database initialization with Alembic migrations via subprocess. | exp: func:init_database(max_retries, retry_delay) → bool, call:range, call:engine.connect, call:test_conn.execute, call:text, call:test_conn.close, call:logger.info, call:asyncio.get_event_loop().run_in_executor, call:subprocess.run, call:logger.debug, call:logger.error, call:asyncio.sleep, call:str(exc).lower, call:logger.warning | dep: asyncio, logging, subprocess, sqlalchemy.ext.asyncio, sqlalchemy.pool, src.config, sqlalchemy
- logging_config.py | Configures structured JSON logging with correlation ID injection, custom formatters, and HTTP request/exception middleware for a FastAPI application. | exp: class:CorrelationIdFilter, method:filter(self, record: logging.LogRecord) → bool, call:get_correlation_id, class:JSONFormatter, method:format(self, record: logging.LogRecord) → str, call:self.formatTime, call:record.getMessage, call:getattr, call:self.formatException, call:json.dumps, method:formatTime(self, record: logging.LogRecord, datefmt) → str, call:time.strftime, call:time.gmtime, class:RequestLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:time.time, call:logger.info, call:call_next, call:int, call:logger.error, call:type, call:traceback.format_exc, class:ExceptionLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:call_next, call:logger.critical, call:traceback.format_exc, func:configure_logging(level) → None, call:JSONFormatter, call:logging.StreamHandler, call:console_handler.setFormatter, call:console_handler.addFilter, call:CorrelationIdFilter, call:root_logger.setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("sqlalchemy.engine").setLevel, call:logger.info, call:logging.getLevelName | dep: json, logging, sys, time, traceback, collections.abc, fastapi, starlette.middleware.base, src.services.shared.correlation
- main.py | FastAPI application entry point that initializes and configures the Headquarter API with routers, middleware, database, health monitoring, and CORS | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api.*
- main.py | Initializes and configures a FastAPI application for the "Headquarter API" with database setup, middleware, routing, and background services. | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api.*
## arch
Layered architecture with Pydantic-based configuration management, async SQLAlchemy with Alembic migrations, structured JSON logging with correlation ID tracing, and modular FastAPI setup with middleware pipeline and health monitoring.
Layered configuration with Pydantic settings, async SQLAlchemy with Alembic migration integration, structured JSON logging with correlation ID tracking, and FastAPI middleware/routing setup.
## tags
src, database, logging, call:logger.info, api, middleware, fastapi, filter
## symbols
@@ -30,6 +30,6 @@ src, database, logging, call:logger.info, api, middleware, fastapi, filter
- change src config
read: config.py, logging_config.py
- explore src subdirectories
index: apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md, apps/api/src/models/.pi-map.index.md
index: apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md, apps/api/src/headquarter_api.egg-info/.pi-map.index.md
## dirty
-
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/api
## role
Defines reusable Pydantic validators for API request/response schema validation across the API layer.
Defines reusable API validation utilities and package structure for FastAPI router organization.
## parent
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
+2 -2
View File
@@ -4,12 +4,12 @@ dir: apps/api/src/api
index: apps/api/src/api/.pi-map.index.md
## role
Defines reusable Pydantic validators for API request/response schema validation across the API layer.
Defines reusable API validation utilities and package structure for FastAPI router organization.
## files
- __init__.py | Marks the directory as a Python package for API routers.
- shared_validators.py | Provides reusable Pydantic validator functions for API schema validation including mount paths, files, environment variables, and volume mounts. | exp: func:validate_mount_path(v: str | None) → str | None, call:v.startswith, raise:ValueError, func:validate_files(v: dict | None, max_size_bytes) → dict | None, call:v.items, call:path.startswith, call:len, call:content.encode, raise:ValueError, func:validate_env_vars(v: dict | None) → dict | None, call:isinstance, raise:ValueError, func:validate_volumes(v: list | None) → list | None, call:isinstance, call:enumerate, raise:ValueError
## arch
Utility module pattern providing shared, composable validation functions using Pydantic's validator system for common container/docker-like domain objects (mounts, volumes, files, env vars).
Modular utility package with shared Pydantic validators for cross-cutting API schema concerns, following separation of validation logic from route handlers.
## tags
validate, raise:value, error, call:isinstance, mount, api, init, path
## symbols
+2 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/api/tool
## role
Provides FastAPI REST endpoints for managing the complete lifecycle of containerized tools, including type definitions, instances, sessions, and validation.
Provides FastAPI REST API endpoints and routers for managing the complete lifecycle of Docker-based tools, including type definitions, instances, sessions, and lifecycle operations.
## parent
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
@@ -13,6 +13,7 @@ map: apps/api/src/api/.pi-map.md
- sessions.py
- tool_definitions.py
- tool_instances.py
- tool_lifecycle.py
- tool_types.py
- tool_types_validation.py
## links
+6 -5
View File
@@ -4,16 +4,17 @@ dir: apps/api/src/api/tool
index: apps/api/src/api/tool/.pi-map.index.md
## role
Provides FastAPI REST endpoints for managing the complete lifecycle of containerized tools, including type definitions, instances, sessions, and validation.
Provides FastAPI REST API endpoints and routers for managing the complete lifecycle of Docker-based tools, including type definitions, instances, sessions, and lifecycle operations.
## files
- __init__.py | Aggregates and exports tool-related API routers from submodules for centralized access | dep: src.api.tool.sessions, src.api.tool.tool_definitions, src.api.tool.tool_instances, src.api.tool.tool_types
- sessions.py | Provides a FastAPI endpoint to retrieve all active tool sessions (running instances) for the currently authenticated user with related metadata. | exp: func:get_user_sessions(user_id, session) → dict, call:_get_user, call:session.execute, call:select(ToolInstance) .where(ToolInstance.owner_id == user_id) .where( ToolInstance.status.in_( ["running", "building", "pending", "stopped", "error"] ) ) .order_by, call:ToolInstance.status.in_, call:ToolInstance.created_at.desc, call:result.scalars().all, call:session.get, call:sessions.append, call:str, call:instance.created_at.isoformat | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models
- __init__.py | Aggregates and exports all tool-related API routers from submodules as a unified module interface. | dep: src.api.tool.sessions, src.api.tool.tool_definitions, src.api.tool.tool_instances, src.api.tool.tool_lifecycle, src.api.tool.tool_types
- sessions.py | API endpoint that retrieves all active tool instances (sessions) for the currently authenticated user with related metadata | exp: func:get_user_sessions(user_id, session) → dict, call:_get_user, call:session.execute, call:select(ToolInstance) .where(ToolInstance.owner_id == user_id) .where( ToolInstance.status.in_( ["running", "building", "pending", "stopped", "error"] ) ) .order_by, call:ToolInstance.status.in_, call:ToolInstance.created_at.desc, call:result.scalars().all, call:session.get, call:sessions.append, call:str, call:instance.created_at.isoformat | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models
- tool_definitions.py | FastAPI router providing CRUD endpoints and manifest compilation for tool definition manifests stored in a database. | exp: class:CreateToolDefinitionRequest, class:UpdateToolDefinitionRequest, func:create_tool_definition(data: CreateToolDefinitionRequest, user_id, session) → dict, call:uuid.UUID, call:session.get, call:session.execute, call:select(ToolDefinitionManifest).where, call:existing.scalar_one_or_none, call:ToolDefinitionManifest, call:session.add, call:session.commit, call:session.refresh, call:logger.info, call:str, call:definition.created_at.isoformat, raise:HTTPException, func:list_tool_definitions(user_id, session, include_bases) → dict, call:select, call:query.where, call:ToolDefinitionManifest.is_base.is_, call:session.execute, call:query.order_by, call:ToolDefinitionManifest.created_at.desc, call:result.scalars().all, call:str, call:d.created_at.isoformat, func:get_tool_definition(definition_id: uuid.UUID, user_id, session) → dict, call:session.get, call:str, call:definition.created_at.isoformat, call:definition.updated_at.isoformat, raise:HTTPException, func:update_tool_definition(definition_id: uuid.UUID, data: UpdateToolDefinitionRequest, user_id, session) → dict, call:session.get, call:session.commit, call:session.refresh, call:logger.info, call:str, call:definition.updated_at.isoformat, raise:HTTPException, func:delete_tool_definition(definition_id: uuid.UUID, user_id, session) → dict, call:session.get, call:session.execute, call:select(ToolType).where, call:result.scalars().all, call:", ".join, call:session.delete, call:session.commit, call:logger.info, call:str, raise:HTTPException, func:compile_tool_definition(definition_id: uuid.UUID, user_id, session) → dict, call:session.get, call:dict, call:resolve_base, call:deep_merge, call:compile_dockerfile, call:compile_entrypoint, call:compute_image_tag, call:compile_compose, call:session.commit, call:str, raise:HTTPException | dep: logging, uuid, fastapi, pydantic, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.build.manifest_compiler
- tool_instances.py | Defines FastAPI REST endpoints for managing Docker-based tool instances within projects, including CRUD operations, lifecycle control (start/stop/restart), logs, health checks, and tunnel management. | exp: func:create_instance(project_id: uuid.UUID, repo_id: uuid.UUID, data: CreateInstanceRequest, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:create_tool_instance, call:str, call:instance.created_at.isoformat, raise:HTTPException, func:list_instances(project_id: uuid.UUID, repo_id: uuid.UUID, user_id, session) → list[dict], call:_get_user, call:_get_owned_project, call:session.execute, call:select(ToolInstance) .where(ToolInstance.repository_id == repo_id) .where(ToolInstance.owner_id == user_id) .order_by, call:ToolInstance.created_at.desc, call:result.scalars().all, call:str, call:i.created_at.isoformat, func:get_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_status, call:str, call:instance.created_at.isoformat, call:instance.last_started_at.isoformat, raise:HTTPException, func:rename_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:data.get("display_name", "").strip, call:rename_tool_instance, call:str, raise:HTTPException, func:start_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, data, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:start_tool_instance, raise:HTTPException, func:stop_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:stop_tool_instance, raise:HTTPException, func:restart_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:restart_tool_instance, raise:HTTPException, func:delete_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, force, user_id, session) → None, call:_get_user, call:_get_owned_project, call:delete_tool_instance, call:str, call:detail.lower, raise:HTTPException, func:get_instance_logs(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, tail, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_logs, raise:HTTPException, func:recreate_tunnel_endpoint(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:recreate_instance_tunnel, raise:HTTPException, func:check_instance_tunnel_health(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_status, call:instance.probe_result.get, call:"\n".join, call:check_tunnel_health, call:tunnel_health.get, raise:HTTPException, func:get_instance_events(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, limit, user_id, session) → list[dict], call:_get_user, call:_get_owned_project, call:session.get, call:session.execute, call:select(InstanceEvent) .where(InstanceEvent.instance_id == instance_id) .order_by(InstanceEvent.created_at.desc()) .limit, call:InstanceEvent.created_at.desc, call:result.scalars().all, call:str, call:row.created_at.isoformat, raise:HTTPException, func:proxy_to_instance(request: Request, project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, path, user_id, session) → Response, call:session.get, call:str, call:dict, call:headers.pop, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:response_headers.pop, call:Response, raise:HTTPException | dep: logging, uuid, httpx, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.docker, src.services.shared.tunnel, src.schemas.tool, src.services.tool.instance_service
- tool_instances.py | Provides FastAPI REST endpoints for managing tool instances including CRUD operations, logs, health checks, tunnel recreation, event history, and HTTP proxying to running containers. | exp: func:create_instance(project_id: uuid.UUID, repo_id: uuid.UUID, data: CreateInstanceRequest, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:create_tool_instance, call:str, call:instance.created_at.isoformat, raise:HTTPException, func:list_instances(project_id: uuid.UUID, repo_id: uuid.UUID, user_id, session) → list[dict], call:_get_user, call:_get_owned_project, call:session.execute, call:select(ToolInstance) .where(ToolInstance.repository_id == repo_id) .where(ToolInstance.owner_id == user_id) .order_by, call:ToolInstance.created_at.desc, call:result.scalars().all, call:str, call:i.created_at.isoformat, func:get_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_status, call:str, call:instance.created_at.isoformat, call:instance.last_started_at.isoformat, raise:HTTPException, func:rename_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:data.get("display_name", "").strip, call:rename_tool_instance, call:str, raise:HTTPException, func:get_instance_logs(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, tail, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_logs, raise:HTTPException, func:recreate_tunnel_endpoint(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:recreate_instance_tunnel, raise:HTTPException, func:check_instance_tunnel_health(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_status, call:instance.probe_result.get, call:"\n".join, call:check_tunnel_health, call:tunnel_health.get, raise:HTTPException, func:get_instance_events(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, limit, user_id, session) → list[dict], call:_get_user, call:_get_owned_project, call:session.get, call:session.execute, call:select(InstanceEvent) .where(InstanceEvent.instance_id == instance_id) .order_by(InstanceEvent.created_at.desc()) .limit, call:InstanceEvent.created_at.desc, call:result.scalars().all, call:str, call:row.created_at.isoformat, raise:HTTPException, func:proxy_to_instance(request: Request, project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, path, user_id, session) → Response, call:session.get, call:str, call:dict, call:headers.pop, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:response_headers.pop, call:Response, raise:HTTPException | dep: logging, uuid, httpx, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.docker, src.services.shared.tunnel, src.schemas.tool, src.services.tool.instance_service
- tool_lifecycle.py | FastAPI router providing REST endpoints for managing Docker-based tool instance lifecycle operations (start, stop, restart, delete). | exp: func:start_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, data, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:start_tool_instance, raise:HTTPException, func:stop_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:stop_tool_instance, raise:HTTPException, func:restart_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:restart_tool_instance, raise:HTTPException, func:delete_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, force, user_id, session) → None, call:_get_user, call:_get_owned_project, call:delete_tool_instance, call:str, call:detail.lower, raise:HTTPException | dep: logging, uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.schemas.tool, src.services.tool.instance_service
- tool_types.py | Defines FastAPI routes for CRUD operations and validation of Docker tool types with compose/dockerfile/manifest definitions. | exp: func:_require_admin(user: User) → None, func:create_tool_type(data: ToolTypeCreate, user_id, session) → ToolType, call:_get_user, call:_require_admin, call:session.scalar, call:select(ToolType).where, call:ToolType, call:session.add, call:session.commit, call:session.refresh, raise:HTTPException, func:list_tool_types(user_id, session) → list[ToolType], call:_get_user, call:session.execute, call:select(ToolType).order_by, call:list, call:result.scalars().all, func:get_tool_type(tool_type_id: uuid.UUID, user_id, session) → ToolType, call:_get_user, call:session.get, raise:HTTPException, func:update_tool_type(tool_type_id: uuid.UUID, data: ToolTypeUpdate, user_id, session) → ToolType, call:_get_user, call:_require_admin, call:session.get, call:data.model_dump, call:update_data.get, call:validate_compose_yaml, call:check_port_exposed, call:validate_required_variables, call:update_data.items, call:setattr, call:session.commit, call:session.refresh, raise:HTTPException, func:validate_tool_type_template(data: ToolTypeValidateRequest, user_id, session) → dict, call:_get_user, call:errors.append, call:validate_compose_yaml, call:str, call:data.dockerfile_template.strip().startswith, call:len, func:validate_tool_type(tool_type_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:session.get, call:errors.append, call:validate_compose_yaml, call:str, call:tool_type.dockerfile_template.strip().startswith, call:len, raise:HTTPException, func:delete_tool_type(tool_type_id: uuid.UUID, user_id, session) → None, call:_get_user, call:_require_admin, call:session.get, call:session.delete, call:session.commit, raise:HTTPException | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.api.tool.tool_types_validation, src.auth.dependencies, src.models, src.models.user, src.schemas.tool
- tool_types_validation.py | Validates Docker Compose YAML templates by sanitizing template variables, parsing YAML, checking required structure, verifying port exposure, and ensuring required variables are present. | exp: func:sanitize_template_vars(template: str) → str, call:re.sub, func:validate_compose_yaml(template: str) → dict, call:sanitize_template_vars, call:yaml.safe_load, call:isinstance, raise:ValueError, func:check_port_exposed(parsed: dict, port: int) → bool, call:str, call:isinstance, call:parsed["services"].values, func:validate_required_variables(template: str, variables: list[str]) → None, raise:HTTPException | dep: re, yaml, fastapi, fastapi.HTTPException, fastapi.status
## arch
Modular router composition with domain-driven separation (types/instances/sessions/definitions), Docker-centric architecture using compose/dockerfile manifests, and validation-layer pattern for YAML sanitization and structural verification.
Modular FastAPI router composition pattern with separated concerns across CRUD operations, validation, lifecycle management, and proxying, aggregated through `__init__.py` for unified module interface.
## tags
get, call:, tool, raise:httpexception, user, instance, call:str, call:session.get
## symbols
+2
View File
@@ -3,11 +3,13 @@
from src.api.tool.sessions import sessions_router
from src.api.tool.tool_definitions import router as tool_definitions_router
from src.api.tool.tool_instances import router as tool_instances_router
from src.api.tool.tool_lifecycle import router as tool_lifecycle_router
from src.api.tool.tool_types import router as tool_types_router
__all__ = [
"sessions_router",
"tool_definitions_router",
"tool_instances_router",
"tool_lifecycle_router",
"tool_types_router",
]
+1 -115
View File
@@ -24,15 +24,11 @@ from src.auth.dependencies import (
from src.models import ToolInstance
from src.services.docker import get_container_logs, get_container_status
from src.services.shared.tunnel import check_tunnel_health
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
from src.schemas.tool import CreateInstanceRequest
from src.services.tool.instance_service import (
create_tool_instance,
delete_tool_instance,
recreate_instance_tunnel,
rename_tool_instance,
restart_tool_instance,
start_tool_instance,
stop_tool_instance,
)
logger = logging.getLogger(__name__)
@@ -190,116 +186,6 @@ async def rename_instance(
}
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/start",
summary="Start instance",
description="Start a tool instance using Docker Compose.",
)
async def start_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
data: StartInstanceRequest | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
return await start_tool_instance(
session, user_id, project_id, repo_id, instance_id, data
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop",
summary="Stop instance",
description="Stop a running tool instance.",
)
async def stop_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
return await stop_tool_instance(session, user_id, project_id, repo_id, instance_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart",
summary="Restart instance",
description="Restart a tool instance.",
)
async def restart_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
return await restart_tool_instance(
session, user_id, project_id, repo_id, instance_id
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)
@router.delete(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
summary="Delete instance",
description="Delete a tool instance and remove its Docker containers and files.",
)
async def delete_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
force: bool = False,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
await delete_tool_instance(
session, user_id, project_id, repo_id, instance_id, force
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
except RuntimeError as exc:
detail = str(exc)
if "uncommitted changes" in detail.lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Repository has uncommitted changes",
"changed_files": detail,
"force_required": True,
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail
)
@router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs",
summary="Get instance logs",
+135
View File
@@ -0,0 +1,135 @@
"""Tool instance lifecycle API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import (
_get_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.schemas.tool import StartInstanceRequest
from src.services.tool.instance_service import (
delete_tool_instance,
restart_tool_instance,
start_tool_instance,
stop_tool_instance,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects", tags=["tool-instances"])
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/start",
summary="Start instance",
description="Start a tool instance using Docker Compose.",
)
async def start_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
data: StartInstanceRequest | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
return await start_tool_instance(
session, user_id, project_id, repo_id, instance_id, data
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop",
summary="Stop instance",
description="Stop a running tool instance.",
)
async def stop_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
return await stop_tool_instance(session, user_id, project_id, repo_id, instance_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart",
summary="Restart instance",
description="Restart a tool instance.",
)
async def restart_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
return await restart_tool_instance(
session, user_id, project_id, repo_id, instance_id
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)
@router.delete(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
summary="Delete instance",
description="Delete a tool instance and remove its Docker containers and files.",
)
async def delete_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
force: bool = False,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
await delete_tool_instance(
session, user_id, project_id, repo_id, instance_id, force
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
except RuntimeError as exc:
detail = str(exc)
if "uncommitted changes" in detail.lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Repository has uncommitted changes",
"changed_files": detail,
"force_required": True,
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail
)
+2
View File
@@ -21,6 +21,7 @@ from src.api.tool import (
sessions_router,
tool_definitions_router,
tool_instances_router,
tool_lifecycle_router,
tool_types_router,
)
from src.api.user import auth_router, ssh_keys_router, users_router
@@ -165,6 +166,7 @@ app.include_router(tool_types_router)
app.include_router(tool_definitions_router)
app.include_router(config_profiles_router)
app.include_router(tool_instances_router)
app.include_router(tool_lifecycle_router)
app.include_router(sessions_router)
app.include_router(instance_proxy_router)
app.include_router(terminal_router)
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web
## role
Frontend web application for a React-based developer workspace with code editing, terminal, and routing capabilities.
Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project.
## parent
index: apps/.pi-map.index.md
map: apps/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/web
index: apps/web/.pi-map.index.md
## role
Frontend web application for a React-based developer workspace with code editing, terminal, and routing capabilities.
Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project.
## files
- .env.example | Template file defining example environment variables for frontend API and application URL configuration
- .eslintrc.cjs | Configures ESLint for a TypeScript browser project with modern ECMAScript module support | dep: @typescript-eslint/parser, @typescript-eslint/eslint-plugin, eslint
@@ -16,7 +16,7 @@ Frontend web application for a React-based developer workspace with code editing
- tsconfig.json | TypeScript configuration file for a React project using Vite with modern ES2020 target and bundler module resolution | dep: typescript, react, vite
- vite.config.ts | Configures Vite build tool for a React project with custom dev server port and Vitest test settings. | dep: vite, @vitejs/plugin-react
## arch
Modern React SPA built with Vite/TypeScript, served by nginx in a Docker container, using ES modules with Vitest for testing and ESLint for code quality.
Modern React SPA built with Vite and TypeScript, containerized via multi-stage Docker with nginx serving, featuring standard tooling (ESLint, Vitest) and client-side routing.
## tags
react, eslint, vite, typescript, dom, application, nginx, web
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src
## role
Frontend web application entry point and core infrastructure for a React-based project management or development platform.
Provides the core web application entry point, routing infrastructure, and shared domain type definitions for a React-based frontend.
## parent
index: apps/web/.pi-map.index.md
map: apps/web/.pi-map.md
+2 -2
View File
@@ -4,13 +4,13 @@ dir: apps/web/src
index: apps/web/src/.pi-map.index.md
## role
Frontend web application entry point and core infrastructure for a React-based project management or development platform.
Provides the core web application entry point, routing infrastructure, and shared domain type definitions for a React-based frontend.
## files
- main.tsx | Bootstraps a React application with routing, authentication, and session management providers. | dep: react, react-dom/client, react-router-dom, ./router, ./state/auth, ./state/sessions, ./styles/tokens.css, ./styles/global.css, ./styles/utilities.css, ./styles/syntax-highlight.css, ./styles/pages/git-history.css, ./styles/pages/projects.css, ./styles/pages/sessions.css, ./styles/pages/ssh-keys.css, ./styles/pages/workspace-detail.css, ./styles/pages/workspaces.css, react-dom
- router.tsx | Defines the React Router configuration for a web application with protected routes, nested layouts, and redirects. | exp: AppRouter | dep: react-router-dom, ./components/app-shell, ./components/protected-route, ./pages/DashboardPage, ./pages/PlaceholderPage, ./pages/ProfilePage, ./pages/ProjectsPage, ./pages/GitRepositoriesPage, ./pages/GitHistoryPage, ./pages/ProjectSettingsPage, ./pages/SettingsPage, ./pages/TerminalPage, ./pages/ToolWorkshopPage, ./pages/SshKeysPage, ./pages/ConfigProfilesPage, ./pages/SessionsPage, ./pages/WorkspacesPage, ./pages/WorkspaceDetailPage
- types.ts | Defines TypeScript type definitions for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos
## arch
Layered React SPA architecture with provider-based dependency injection, declarative routing with nested layouts and route guards, and centralized TypeScript type definitions for domain entities.
Modular React SPA using React Router v6 with nested route layouts, protected route guards via authentication context providers, and centralized TypeScript type definitions for cross-cutting domain models.
## tags
pages, styles, css, router, react, session, dom, project
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/components
## role
Provides reusable, foundational React UI components for layout, authentication, data display, icons, code editing, and notifications across the web application.
Provides shared, reusable React UI components for the web application including layout shell, navigation, authentication guards, data display states, icons, code editing, syntax highlighting, and toast notifications.
## parent
index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/web/src/components
index: apps/web/src/components/.pi-map.index.md
## role
Provides reusable, foundational React UI components for layout, authentication, data display, icons, code editing, and notifications across the web application.
Provides shared, reusable React UI components for the web application including layout shell, navigation, authentication guards, data display states, icons, code editing, syntax highlighting, and toast notifications.
## files
- app-shell.tsx | Renders the main application shell layout with navigation, session management, and responsive mobile/desktop views for a React Router-based app. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ../state/session-operations, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./features/session/session-progress-panel, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons
- code-editor.tsx | A React component that renders a syntax-highlighted code editor with line numbers using react-simple-code-editor. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language
@@ -16,7 +16,7 @@ Provides reusable, foundational React UI components for layout, authentication,
- toast-rules.test.ts | Unit tests for mapping instance events to toast notification categories and severities | dep: vitest, ./toast-rules, ../types/events
- toast-rules.ts | Maps instance events to toast notifications with deduplication logic to prevent spam | exp: func:mapEventToCategory(event: InstanceEventPayload) → string, call:event.event.startsWith, func:mapEventToSeverity(event: InstanceEventPayload) → "info" | "warning" | "error" | "success", func:handleEventToast(event: InstanceEventPayload) → void, call:shouldShowToast, call:toast.info, call:toast.success, call:toast.warning, call:toast.error, func:clearToastDedup() → void, call:lastToastTime.clear | dep: ../state/toast, ../types/events, toast state module, InstanceEventPayload type
## arch
Modular component architecture with separation of concerns—each component handles a single responsibility, with dedicated test files alongside implementation, and higher-level components (app-shell, protected-route) composing lower-level primitives (icon, data-states, toast-rules).
Component-based architecture with clear separation of concerns: layout/navigation (app-shell), authentication (protected-route), data presentation (data-states, syntax-highlighter, code-editor), utilities (icon, toast-rules), and co-located test files following a feature-organized structure with semantic naming conventions.
## tags
toast, state, react, icon, code, event, editor, protected
## symbols
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features
## role
Contains reusable React components that implement specific user-facing functionality and business logic features across the web application.
Contains reusable UI components organized by feature domains for the web application.
## parent
index: apps/web/src/components/.pi-map.index.md
map: apps/web/src/components/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps/web/src/components/features
index: apps/web/src/components/features/.pi-map.index.md
## role
Contains reusable React components that implement specific user-facing functionality and business logic features across the web application.
Contains reusable UI components organized by feature domains for the web application.
## files
## arch
Feature-based component organization with domain-specific grouping, likely using composition patterns and co-located feature logic (hooks, utils, sub-components) following a modular frontend architecture.
Feature-based colocation pattern where components are grouped by business domain rather than technical type, enabling scalable modular development with clear separation of concerns.
## tags
-
## symbols
@@ -4,114 +4,114 @@ import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
cleanup();
cleanup();
});
import { ProjectCard } from "./ProjectCard";
import type { ProjectWithRepos } from "../../../types";
const mockProject: ProjectWithRepos = {
id: "proj-1",
name: "Alpha Project",
description: "First project",
owner_id: "user-1",
default_ssh_key_id: null,
created_at: "2026-06-01T00:00:00Z",
repositories: [
{
id: "repo-1",
name: "my-repo",
remote_url: "https://example.com/repo.git",
workspaces: [
{
id: "ws-1",
name: "dev",
branch: "main",
status: "ready",
instance_count: 2,
},
],
},
],
id: "proj-1",
name: "Alpha Project",
description: "First project",
owner_id: "user-1",
default_ssh_key_id: null,
created_at: "2026-06-01T00:00:00Z",
repositories: [
{
id: "repo-1",
name: "my-repo",
remote_url: "https://example.com/repo.git",
workspaces: [
{
id: "ws-1",
name: "dev",
branch: "main",
status: "ready",
instance_count: 2,
},
],
},
],
};
describe("ProjectCard", () => {
it("renders project name and repository", () => {
render(
<MemoryRouter>
<ProjectCard
project={mockProject}
expanded={true}
deleteConfirm={false}
workspaceLoading={null}
showCreateForm={null}
onToggle={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onConfirmDelete={vi.fn()}
onCancelDelete={vi.fn()}
onCreateWorkspace={vi.fn()}
onWorkspaceAction={vi.fn()}
onCancelCreate={vi.fn()}
onCreated={vi.fn()}
/>
</MemoryRouter>
);
it("renders project name and repository", () => {
render(
<MemoryRouter>
<ProjectCard
project={mockProject}
expanded={true}
deleteConfirm={false}
workspaceLoading={null}
showCreateForm={null}
onToggle={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onConfirmDelete={vi.fn()}
onCancelDelete={vi.fn()}
onCreateWorkspace={vi.fn()}
onWorkspaceAction={vi.fn()}
onCancelCreate={vi.fn()}
onCreated={vi.fn()}
/>
</MemoryRouter>,
);
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
expect(screen.getByText("my-repo")).toBeInTheDocument();
expect(screen.getByText("dev")).toBeInTheDocument();
});
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
expect(screen.getByText("my-repo")).toBeInTheDocument();
expect(screen.getByText("dev")).toBeInTheDocument();
});
it("calls onCreateWorkspace when new workspace button is clicked", () => {
const onCreateWorkspace = vi.fn();
render(
<MemoryRouter>
<ProjectCard
project={mockProject}
expanded={true}
deleteConfirm={false}
workspaceLoading={null}
showCreateForm={null}
onToggle={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onConfirmDelete={vi.fn()}
onCancelDelete={vi.fn()}
onCreateWorkspace={onCreateWorkspace}
onWorkspaceAction={vi.fn()}
onCancelCreate={vi.fn()}
onCreated={vi.fn()}
/>
</MemoryRouter>
);
it("calls onCreateWorkspace when new workspace button is clicked", () => {
const onCreateWorkspace = vi.fn();
render(
<MemoryRouter>
<ProjectCard
project={mockProject}
expanded={true}
deleteConfirm={false}
workspaceLoading={null}
showCreateForm={null}
onToggle={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onConfirmDelete={vi.fn()}
onCancelDelete={vi.fn()}
onCreateWorkspace={onCreateWorkspace}
onWorkspaceAction={vi.fn()}
onCancelCreate={vi.fn()}
onCreated={vi.fn()}
/>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: /new workspace/i }));
expect(onCreateWorkspace).toHaveBeenCalledWith("repo-1");
});
fireEvent.click(screen.getByRole("button", { name: /new workspace/i }));
expect(onCreateWorkspace).toHaveBeenCalledWith("repo-1");
});
it("shows delete confirmation", () => {
render(
<MemoryRouter>
<ProjectCard
project={mockProject}
expanded={true}
deleteConfirm={true}
workspaceLoading={null}
showCreateForm={null}
onToggle={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onConfirmDelete={vi.fn()}
onCancelDelete={vi.fn()}
onCreateWorkspace={vi.fn()}
onWorkspaceAction={vi.fn()}
onCancelCreate={vi.fn()}
onCreated={vi.fn()}
/>
</MemoryRouter>
);
it("shows delete confirmation", () => {
render(
<MemoryRouter>
<ProjectCard
project={mockProject}
expanded={true}
deleteConfirm={true}
workspaceLoading={null}
showCreateForm={null}
onToggle={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onConfirmDelete={vi.fn()}
onCancelDelete={vi.fn()}
onCreateWorkspace={vi.fn()}
onWorkspaceAction={vi.fn()}
onCancelCreate={vi.fn()}
onCreated={vi.fn()}
/>
</MemoryRouter>,
);
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
});
@@ -2,24 +2,28 @@
dir: apps/web/src/components/features/workspace
## role
Provides the workspace UI shell and container components that orchestrate the repository browsing, editing, and tool execution environment for the web application.
Provides UI components for workspace management, navigation, and detail views in the web application.
## parent
index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md
## children
-
## files
- FileBrowser.tsx
- WorkspaceLayout.tsx
- workspace-card.tsx
- workspace-create-form.tsx
- workspace-detail-header.tsx
- workspace-file-panel.tsx
- workspace-git-panel.tsx
- workspace-header.tsx
- workspace-instance-chips.tsx
- workspace-settings-panel.tsx
- workspace-tab-bar.tsx
- workspace-tools-panel.tsx
## links
index: apps/web/src/components/features/workspace/.pi-map.index.md
map: apps/web/src/components/features/workspace/.pi-map.md
## workflows
- change workspace behavior
read: FileBrowser.tsx, WorkspaceLayout.tsx, workspace-card.tsx
read: workspace-card.tsx, workspace-create-form.tsx, workspace-detail-header.tsx
## dirty
-
@@ -4,29 +4,33 @@ dir: apps/web/src/components/features/workspace
index: apps/web/src/components/features/workspace/.pi-map.index.md
## role
Provides the workspace UI shell and container components that orchestrate the repository browsing, editing, and tool execution environment for the web application.
Provides UI components for workspace management, navigation, and detail views in the web application.
## files
- FileBrowser.tsx | A React component that displays a browsable file tree for a git repository with directory navigation, file status indicators, and URL-based state management. | exp: FileBrowser | dep: react, react-router-dom, ../../../api/client, ../../data-states, ../../icon, ../../../api/git-repositories, apiClient, EmptyState, Icon
- WorkspaceLayout.tsx | Renders a responsive workspace layout that switches between mobile tab-based navigation and desktop sidebar/main panel layout for repository file browsing, editing, git operations, and terminal tools. | exp: WorkspaceLayout | dep: ../../icon, ./FileBrowser, ../git/file-editor, ../git/commit-panel, ../git/git-toolbar, ../tool/instance-list, ../../../api/git-repositories, ../../../api/tool-types, ../../../hooks/use-repo-workspace, Icon, FileBrowser, FileEditor, CommitPanel, GitToolbar, InstanceList, GitRepository, GitStatus, ToolType, Project
- workspace-card.tsx | React card component that displays workspace information with status, metadata, and action buttons | exp: WorkspaceCardProps, func:WorkspaceCard({ workspace, loading = false, onStartTool, onSync, onDelete, }: WorkspaceCardProps), call:onStartTool, call:onSync, call:onDelete | dep: react-router-dom, ../../icon, ./workspace-instance-chips, ../../../types/workspace
- workspace-create-form.tsx | React form component for creating workspaces with cascading project/repository/branch selectors and support for both contextual and standalone modes | exp: WorkspaceCreateFormProps, func:WorkspaceCreateForm({ onSubmit, onCancel, defaultProjectId, defaultRepoId, }: WorkspaceCreateFormProps), call:Boolean, call:useState, call:useGitRepo, call:useEffect, call:git.branches.includes, call:setSelectedBranch, call:setIsNewBranch, call:useCallback, call:listProjects, call:setProjects, call:setSelectedProject, call:setError, call:setFetchingProjects, call:loadProjects, call:setRepos, call:setSelectedRepo, call:listRepositories, call:loadRepos, call:setNewBranchName, call:e.preventDefault, call:name.trim, call:newBranchName.trim, call:setSubmitting, call:createWorkspaceTopLevel, call:onSubmit, call:projects.map, call:repos.map, call:setName, call:handleBranchChange, call:git.branches.map | dep: react, ../../icon, ../../../api/projects, ../../../api/git-repositories, ../../../api/workspaces, ../../../hooks/use-git-repo, ../../../types, icon, api/projects, api/git-repositories, api/workspaces, hooks/use-git-repo, types
- workspace-detail-header.tsx | Renders a header component for a workspace detail page displaying breadcrumb navigation and branch information. | exp: WorkspaceDetailHeaderProps, func:WorkspaceDetailHeader({ workspace }: WorkspaceDetailHeaderProps) | dep: ../../icon, icon
- workspace-file-panel.tsx | Renders a file browser and editor panel with Git integration for a workspace detail page. | exp: func:WorkspaceFilePanel({ workspaceId }: WorkspaceFilePanelProps), call:useWorkspaceFiles, call:useWorkspaceGit, call:useState, call:setSelectedPath, call:setIsEditing, call:setEditContent, call:navigateTo, call:loadFile, call:currentPath.split("/").slice(0, -1).join, call:saveFile, call:setCommitMessage, call:commit, call:entries.map, call:handleSelect | dep: react, ../../icon, ../../../hooks/use-workspace-files, ../../../hooks/use-workspace-git, ../../../api/workspace-files, icon, use-workspace-files, use-workspace-git, workspace-files API types
- workspace-git-panel.tsx | Renders a Git panel for a workspace detail page that displays branch selection and commit history. | exp: func:WorkspaceGitPanel({ workspaceId }: WorkspaceGitPanelProps), call:useWorkspaceGit, call:checkout, call:branches.map, call:history.map, call:commit.hash.slice | dep: ../../../hooks/use-workspace-git, React, useWorkspaceGit hook
- workspace-header.tsx | Renders a workspace header component displaying project info with navigation links to history and settings pages. | exp: WorkspaceHeader | dep: react-router-dom, ../../icon, icon
- workspace-instance-chips.tsx | Displays running tool instances for a workspace as clickable status chips with external links. | exp: func:WorkspaceInstanceChips({ workspaceId, }: WorkspaceInstanceChipsProps), call:useState, call:useEffect, call:listWorkspaceInstances, call:setInstances, call:setLoading, call:load, call:instances.map, call:e.stopPropagation | dep: react, ../../../api/workspace-instances, ../../../api/sessions
- workspace-settings-panel.tsx | Displays a read-only settings panel showing workspace metadata on a workspace detail page. | exp: func:WorkspaceSettingsPanel({ workspace }: WorkspaceSettingsPanelProps) | dep: ../../../types/workspace, React, types/workspace
- workspace-tab-bar.tsx | Renders a tab bar component for workspace navigation with desktop and mobile variants. | exp: WorkspaceTab, func:WorkspaceTabBar({ active, onChange }: WorkspaceTabBarProps), call:TABS.map, call:onChange, func:WorkspaceMobileTabBar({ active, onChange }: WorkspaceTabBarProps), call:TABS.map, call:onChange | dep: ../../icon, icon
- workspace-tools-panel.tsx | Displays and manages running tool instances for a workspace with ability to start new tools via modal | exp: func:WorkspaceToolsPanel({ workspace }: WorkspaceToolsPanelProps), call:useWorkspaceInstances, call:useState, call:setShowModal, call:instances.map, call:e.stopPropagation, call:refresh | dep: react, ../../icon, ../tool/tool-starter, ../../../hooks/use-workspace-instances, ../../../types/workspace, icon, tool-starter, use-workspace-instances
## arch
Compound component architecture with responsive layout switching (mobile/desktop), URL-driven state management for file navigation, and feature-based composition combining card/list views, forms, headers, and dynamic tool instance indicators.
Feature-based component organization with page-specific composite components (header/panel/tab-bar) and atomic display components, following a compound component pattern for workspace detail page layout.
## tags
workspace, call:set, git, api, call:use, branch, projects, react
workspace, call:set, call:use, git, panel, icon, branch, call:on
## symbols
- WorkspaceCard
- WorkspaceCreateForm
- WorkspaceDetailHeader
- WorkspaceFilePanel
- WorkspaceGitPanel
- WorkspaceInstanceChips
- FileBrowser
- WorkspaceLayout
- WorkspaceCardProps
- call:onStartTool
- call:onSync
- WorkspaceSettingsPanel
- WorkspaceTabBar
## workflows
- change workspace behavior
read: FileBrowser.tsx, WorkspaceLayout.tsx, workspace-card.tsx
read: workspace-card.tsx, workspace-create-form.tsx, workspace-detail-header.tsx
## dirty
-
@@ -0,0 +1,31 @@
/** Header for the workspace detail page. */
import { Icon } from "../../icon";
export interface WorkspaceDetailHeaderProps {
workspace: {
name: string;
repo_name: string;
project_name: string;
branch: string;
};
}
export function WorkspaceDetailHeader({ workspace }: WorkspaceDetailHeaderProps) {
return (
<header className="workspace-header">
<div className="workspace-breadcrumb">
<span>{workspace.project_name}</span>
<span className="sep">/</span>
<span>{workspace.repo_name}</span>
<span className="sep">/</span>
<strong>{workspace.name}</strong>
</div>
<div className="workspace-actions">
<span className="branch-badge">
<Icon name="branch" size="sm" /> {workspace.branch}
</span>
</div>
</header>
);
}
@@ -0,0 +1,181 @@
/** Files tab for the workspace detail page. */
import { useState } from "react";
import { Icon } from "../../icon";
import { useWorkspaceFiles } from "../../../hooks/use-workspace-files";
import { useWorkspaceGit } from "../../../hooks/use-workspace-git";
import type { FileEntry } from "../../../api/workspace-files";
interface WorkspaceFilePanelProps {
workspaceId: string;
}
export function WorkspaceFilePanel({ workspaceId }: WorkspaceFilePanelProps) {
const {
entries,
content,
currentPath,
loadFile,
saveFile,
loading,
error,
navigateTo,
} = useWorkspaceFiles(workspaceId);
const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [editContent, setEditContent] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [commitMessage, setCommitMessage] = useState("");
const handleSelect = (entry: FileEntry) => {
if (entry.type === "directory") {
setSelectedPath(null);
setIsEditing(false);
setEditContent(null);
navigateTo(entry.path);
return;
}
setSelectedPath(entry.path);
setIsEditing(false);
setEditContent(null);
loadFile(entry.path);
};
const navigateUp = () => {
if (!currentPath) return;
const parentPath = currentPath.split("/").slice(0, -1).join("/");
navigateTo(parentPath);
setSelectedPath(null);
setIsEditing(false);
setEditContent(null);
};
const handleEdit = () => {
if (content !== null) {
setEditContent(content);
setIsEditing(true);
}
};
const handleSave = async () => {
if (selectedPath && editContent !== null) {
await saveFile(selectedPath, editContent, commitMessage || undefined);
setIsEditing(false);
setCommitMessage("");
}
};
return (
<div className="files-tab">
{status && (
<div className="git-toolbar">
<div className="git-toolbar-status">
{status.modified.length > 0 && (
<span className="status-modified">
M {status.modified.length}
</span>
)}
{status.added.length > 0 && (
<span className="status-added">A {status.added.length}</span>
)}
{status.deleted.length > 0 && (
<span className="status-deleted">D {status.deleted.length}</span>
)}
{status.untracked.length > 0 && (
<span className="status-untracked">
? {status.untracked.length}
</span>
)}
</div>
<div className="git-toolbar-actions">
<input
type="text"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Commit message"
/>
<button
onClick={() => commit(commitMessage)}
disabled={!commitMessage}
type="button"
>
Commit
</button>
<button onClick={push} type="button">Push</button>
<button onClick={pull} type="button">Pull</button>
<button onClick={fetch} type="button">Fetch</button>
</div>
</div>
)}
<div className="files-split">
<div className="file-tree">
{currentPath && (
<button
className="tree-entry tree-up"
onClick={navigateUp}
type="button"
>
<Icon name="folder" size="sm" /> ..
</button>
)}
{loading && <p className="muted">Loading...</p>}
{error && <p className="error-text">{error}</p>}
{entries.map((entry) => (
<button
key={entry.path}
className={`tree-entry ${entry.type} ${selectedPath === entry.path ? "selected" : ""}`}
onClick={() => handleSelect(entry)}
type="button"
>
<Icon
name={entry.type === "directory" ? "folder" : "file"}
size="sm"
/>
{entry.name}
</button>
))}
</div>
<div className="file-viewer">
{selectedPath ? (
<>
<div className="file-viewer-header">
<span>{selectedPath}</span>
{!isEditing && (
<button onClick={handleEdit} type="button">
Edit
</button>
)}
</div>
{isEditing ? (
<>
<textarea
className="file-editor"
value={editContent || ""}
onChange={(e) => setEditContent(e.target.value)}
/>
<div className="file-editor-actions">
<button
onClick={() => setIsEditing(false)}
type="button"
>
Cancel
</button>
<button onClick={handleSave} type="button">
Save
</button>
</div>
</>
) : (
<pre className="file-content">
{content || "Loading..."}
</pre>
)}
</>
) : (
<p className="muted">Select a file to view</p>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
/** Git tab for the workspace detail page. */
import { useWorkspaceGit } from "../../../hooks/use-workspace-git";
interface WorkspaceGitPanelProps {
workspaceId: string;
}
export function WorkspaceGitPanel({ workspaceId }: WorkspaceGitPanelProps) {
const { history, branches, currentBranch, checkout, loading, error } =
useWorkspaceGit(workspaceId);
return (
<div className="git-tab">
<div className="git-tab-header">
<select
value={currentBranch}
onChange={(e) => checkout(e.target.value)}
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
{loading && <p className="muted">Loading history...</p>}
{error && <p className="error-text">{error}</p>}
<div className="commit-history">
{history.map((commit) => (
<div key={commit.hash} className="commit-row">
<span className="commit-hash">{commit.hash.slice(0, 7)}</span>
<span className="commit-message">{commit.message}</span>
<span className="commit-author">{commit.author}</span>
<span className="commit-date">{commit.date}</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,39 @@
/** Settings tab for the workspace detail page. */
import type { Workspace } from "../../../types/workspace";
interface WorkspaceSettingsPanelProps {
workspace: Workspace;
}
export function WorkspaceSettingsPanel({ workspace }: WorkspaceSettingsPanelProps) {
return (
<div className="settings-tab">
<div className="settings-section">
<h3>Workspace Info</h3>
<div className="form-group">
<label>Name</label>
<input type="text" value={workspace.name} readOnly />
</div>
<div className="form-group">
<label>Branch</label>
<input type="text" value={workspace.branch} readOnly />
</div>
<div className="form-group">
<label>Path</label>
<input type="text" value={workspace.path} readOnly />
</div>
<div className="form-group">
<label>Status</label>
<span className={`status-badge ${workspace.status}`}>
{workspace.status}
</span>
</div>
<div className="form-group">
<label>Created</label>
<span>{workspace.created_at}</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,68 @@
/** Tab bar for the workspace detail page. */
import { Icon } from "../../icon";
export type WorkspaceTab = "files" | "git" | "tools" | "settings";
interface Tab {
id: WorkspaceTab;
label: string;
icon: string;
}
const TABS: Tab[] = [
{ id: "files", label: "Files", icon: "folder" },
{ id: "git", label: "Git", icon: "branch" },
{ id: "tools", label: "Tools", icon: "terminal" },
{ id: "settings", label: "Settings", icon: "settings" },
];
interface WorkspaceTabBarProps {
active: WorkspaceTab;
onChange: (tab: WorkspaceTab) => void;
}
export function WorkspaceTabBar({ active, onChange }: WorkspaceTabBarProps) {
return (
<nav className="tab-bar" role="tablist">
{TABS.map((tab) => (
<button
key={tab.id}
className={`tab ${active === tab.id ? "active" : ""}`}
onClick={() => onChange(tab.id)}
role="tab"
aria-selected={active === tab.id}
type="button"
>
<Icon
name={tab.icon as "folder" | "branch" | "terminal" | "settings"}
size="sm"
/>
{tab.label}
</button>
))}
</nav>
);
}
export function WorkspaceMobileTabBar({ active, onChange }: WorkspaceTabBarProps) {
return (
<nav className="mobile-tab-bar" role="tablist">
{TABS.map((tab) => (
<button
key={tab.id}
className={`mobile-tab ${active === tab.id ? "active" : ""}`}
onClick={() => onChange(tab.id)}
role="tab"
aria-selected={active === tab.id}
type="button"
>
<Icon
name={tab.icon as "folder" | "branch" | "terminal" | "settings"}
/>
<span>{tab.label}</span>
</button>
))}
</nav>
);
}
@@ -0,0 +1,81 @@
/** Tools tab for the workspace detail page. */
import { useState } from "react";
import { Icon } from "../../icon";
import { ToolStarter } from "../tool/tool-starter";
import { useWorkspaceInstances } from "../../../hooks/use-workspace-instances";
import type { Workspace } from "../../../types/workspace";
interface WorkspaceToolsPanelProps {
workspace: Workspace;
}
export function WorkspaceToolsPanel({ workspace }: WorkspaceToolsPanelProps) {
const { instances, loading, refresh } = useWorkspaceInstances(workspace.id);
const [showModal, setShowModal] = useState(false);
return (
<div className="tools-tab">
{loading && <p className="muted">Loading instances...</p>}
{instances.length === 0 ? (
<div className="empty-state-card">
<Icon name="terminal" size="lg" />
<h3>No tools running</h3>
<p>Start a tool to begin coding in this workspace</p>
<button
className="btn btn-primary"
onClick={() => setShowModal(true)}
type="button"
>
Start Tool
</button>
</div>
) : (
<>
<div className="instances-grid">
{instances.map((instance) => (
<div
key={instance.id}
className={`instance-card ${instance.status}`}
>
<h4>{instance.display_name}</h4>
<span className="status-badge">{instance.status}</span>
{instance.url && (
<a
href={instance.url}
target={`instance-${instance.id}`}
rel="noreferrer"
>
Open
</a>
)}
</div>
))}
</div>
<button
className="btn btn-primary"
onClick={() => setShowModal(true)}
type="button"
>
Start Another Tool
</button>
</>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Start Tool</h3>
<ToolStarter
workspace={workspace}
onStarted={() => {
setShowModal(false);
void refresh();
}}
onCancel={() => setShowModal(false)}
/>
</div>
</div>
)}
</div>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/pages
## role
Contains all top-level page components that serve as route endpoints for the web application's main navigation areas, each handling a distinct functional domain of the development environment platform.
Contains top-level React page components that serve as route endpoints for the web application's primary feature areas, each handling a distinct domain of the workspace management platform.
## parent
index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md
+6 -6
View File
@@ -4,10 +4,10 @@ dir: apps/web/src/pages
index: apps/web/src/pages/.pi-map.index.md
## role
Contains all top-level page components that serve as route endpoints for the web application's main navigation areas, each handling a distinct functional domain of the development environment platform.
Contains top-level React page components that serve as route endpoints for the web application's primary feature areas, each handling a distinct domain of the workspace management platform.
## files
- ConfigProfilesPage.tsx | Renders a responsive configuration profiles management page with sidebar list and editor panel for desktop, and a dedicated mobile view for creating, editing, and managing config profiles. | exp: ConfigProfilesPage | dep: react, ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-config-profiles, ../components/features/config-profiles/ConfigProfileListSidebar, ../components/features/config-profiles/ConfigProfileEditorPanel, ../components/features/config-profiles/ConfigProfilesMobileView
- DashboardPage.test.tsx | Unit tests for the DashboardPage/ HomePage component verifying overview loading and error retry behavior. | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./DashboardPage, ../state/sessions, ../state/session-operations, DashboardPage, SessionsProvider, SessionOperationsProvider
- DashboardPage.test.tsx | Tests the DashboardPage (HomePage) component's rendering, loading states, and error handling with retry functionality | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./DashboardPage, ../state/sessions, ../state/session-operations, ../api/dashboard, ../api/sessions, ../api/projects, ../api/git-repositories, ../api/tool-types
- DashboardPage.tsx | Renders a dashboard homepage that displays workspace overview, active/recent sessions, summary statistics, and polling health checks for running instances. | exp: HomePage | dep: react, react-router-dom, ../api/dashboard, ../api/sessions, ../components/data-states, ../components/features/session/session-list, ../hooks/use-instance-actions, ../state/sessions
- GitHistoryPage.tsx | Renders a Git commit history page with branch selection, commit list with graph visualization, and a detail panel showing commit metadata, stats, and diffs. | exp: GitHistoryPage | dep: react, react-router-dom, ../api/git-repositories, ../components/data-states, ../components/icon, ../hooks/use-async-data
- GitRepositoriesPage.tsx | Displays and manages a project's Git repositories with CRUD operations including listing, creating, navigating to history, and deleting with confirmation | exp: GitRepositoriesPage | dep: react, react-router-dom, ../api/git-repositories, ../components/data-states, ../components/icon, ../components/features/project/repository-create-dialog, ../hooks/use-async-data
@@ -21,13 +21,13 @@ Contains all top-level page components that serve as route endpoints for the web
- SshKeysPage.tsx | React page component for managing SSH keys including generation, listing, signing, verification, and deletion | exp: SSHKeysPage | dep: react-router-dom, ../components/data-states, ../hooks/use-ssh-keys, ../components/features/ssh-keys/SSHKeyCreateForm, ../components/features/ssh-keys/SSHKeyList
- TerminalPage.tsx | Renders a responsive terminal page that switches between mobile and desktop views based on device type, managing terminal sessions and their interactions. | exp: TerminalPage | dep: react, ../hooks/use-terminal-page, ../components/features/terminal/MobileTerminalView, ../components/features/terminal/DesktopTerminalView, useTerminalPage hook, MobileTerminalView, DesktopTerminalView
- ToolWorkshopPage.tsx | Renders a responsive tool workshop page with sidebar/editor layout for desktop and tabbed mobile view for managing tool types | exp: ToolWorkshopPage | dep: ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-tool-workshop, ../components/features/tool-workshop/ToolTypeListSidebar, ../components/features/tool-workshop/ToolTypeEditorPanel, ../components/features/tool-workshop/ToolWorkshopMobileView, react, use-mobile-viewport, use-tool-workshop, data-states, ToolTypeListSidebar, ToolTypeEditorPanel, ToolWorkshopMobileView
- WorkspaceDetailPage.test.tsx | Tests the WorkspaceDetailPage component rendering workspace headers, tabs, and tab switching behavior | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./WorkspaceDetailPage, @testing-library/jest-dom, WorkspaceDetailPage, use-workspaces, use-workspace-files, use-workspace-git, use-workspace-instances, use-mobile-viewport
- WorkspaceDetailPage.tsx | Renders a workspace detail page with tabbed navigation for files, git, tools, and settings management. | exp: func:WorkspaceDetailPage(), call:useParams, call:useState, call:useMobileViewport, call:useWorkspaces, call:workspaces.find | dep: react, react-router-dom, ../components/icon, ../hooks/use-workspaces, ../hooks/use-workspace-files, ../hooks/use-workspace-git, ../hooks/use-workspace-instances, ../hooks/use-mobile-viewport, ../components/features/tool/tool-starter, ../api/workspace-files, ../types/workspace
- WorkspaceDetailPage.test.tsx | Tests the WorkspaceDetailPage component rendering and tab switching behavior | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./WorkspaceDetailPage, @testing-library/jest-dom, WorkspaceDetailPage, use-workspaces, use-workspace-files, use-workspace-git, use-workspace-instances, use-mobile-viewport
- WorkspaceDetailPage.tsx | Renders a workspace detail page with tabbed navigation for files, git, tools, and settings panels, adapting layout for mobile viewports. | exp: func:WorkspaceDetailPage(), call:useParams, call:useState, call:useMobileViewport, call:useWorkspaces, call:workspaces.find | dep: react, react-router-dom, ../hooks/use-workspaces, ../hooks/use-mobile-viewport, ../components/features/workspace/workspace-detail-header, ../components/features/workspace/workspace-tab-bar, ../components/features/workspace/workspace-file-panel, ../components/features/workspace/workspace-git-panel, ../components/features/workspace/workspace-tools-panel, ../components/features/workspace/workspace-settings-panel, use-workspaces, use-mobile-viewport, workspace-detail-header, workspace-tab-bar, workspace-mobile-tab-bar, workspace-file-panel, workspace-git-panel, workspace-tools-panel, workspace-settings-panel
- WorkspacesPage.tsx | Renders a responsive workspaces management page with separate mobile (list/detail/create views) and desktop (grid with cards) layouts, supporting CRUD operations and tool launching. | exp: func:WorkspacesPage(), call:useMobileViewport, call:useState, call:useWorkspaces, call:useWorkspaceActions, call:actions.delete, call:actions.sync, call:setMobileView, call:refresh, call:setStartWorkspace, call:handleDelete, call:setSelectedWorkspace, call:workspaces.map, call:workspaces.find, call:e.stopPropagation, call:setShowCreate | dep: react, ../components/icon, ../hooks/use-mobile-viewport, ../hooks/use-workspaces, ../hooks/use-workspace-actions, ../components/features/workspace/workspace-card, ../components/features/workspace/workspace-create-form, ../components/features/mobile/mobile-list-view, ../components/features/mobile/mobile-detail-view, ../components/features/mobile/mobile-fab, ../components/features/tool/tool-starter, ../types/workspace
## arch
Follows a page-based routing architecture with responsive dual-layout pattern (mobile/desktop variants), heavy use of tabbed navigation for complex pages, polling-based live data updates, and CRUD-heavy pages with optimistic UI patterns and confirmation dialogs.
Flat page-based architecture with co-located tests, where each page is a self-contained route component implementing responsive mobile/desktop adaptive layouts, tabbed navigation, CRUD operations, and real-time data polling; pages compose shared UI patterns (sidebar/editor splits, card grids, detail panels) and delegate to child components or outlets for nested routing.
## tags
page, components, react, workspace, features, mobile, hooks, settings
page, components, workspace, features, mobile, react, settings, hooks
## symbols
- WorkspaceDetailPage
- WorkspacesPage
@@ -1,5 +1,11 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -117,7 +123,9 @@ describe("WorkspaceDetailPage", () => {
);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /settings/i })).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: /settings/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("tab", { name: /settings/i }));
+27 -411
View File
@@ -2,21 +2,22 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Icon } from "../components/icon";
import { useWorkspaces } from "../hooks/use-workspaces";
import { useWorkspaceFiles } from "../hooks/use-workspace-files";
import { useWorkspaceGit } from "../hooks/use-workspace-git";
import { useWorkspaceInstances } from "../hooks/use-workspace-instances";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { ToolStarter } from "../components/features/tool/tool-starter";
import type { FileEntry } from "../api/workspace-files";
import type { Workspace } from "../types/workspace";
type Tab = "files" | "git" | "tools" | "settings";
import { WorkspaceDetailHeader } from "../components/features/workspace/workspace-detail-header";
import {
WorkspaceMobileTabBar,
WorkspaceTabBar,
type WorkspaceTab,
} from "../components/features/workspace/workspace-tab-bar";
import { WorkspaceFilePanel } from "../components/features/workspace/workspace-file-panel";
import { WorkspaceGitPanel } from "../components/features/workspace/workspace-git-panel";
import { WorkspaceToolsPanel } from "../components/features/workspace/workspace-tools-panel";
import { WorkspaceSettingsPanel } from "../components/features/workspace/workspace-settings-panel";
export function WorkspaceDetailPage() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const [activeTab, setActiveTab] = useState<Tab>("files");
const [activeTab, setActiveTab] = useState<WorkspaceTab>("files");
const isMobile = useMobileViewport();
const { workspaces, loading: wsLoading } = useWorkspaces();
@@ -37,410 +38,25 @@ export function WorkspaceDetailPage() {
return (
<div className={`workspace-detail ${isMobile ? "mobile" : ""}`}>
<WorkspaceHeader workspace={workspace} />
<TabBar active={activeTab} onChange={setActiveTab} />
<WorkspaceDetailHeader workspace={workspace} />
<WorkspaceTabBar active={activeTab} onChange={setActiveTab} />
<div className="workspace-content">
{activeTab === "files" && <FilesTab workspaceId={workspace.id} />}
{activeTab === "git" && <GitTab workspaceId={workspace.id} />}
{activeTab === "tools" && <ToolsTab workspace={workspace} />}
{activeTab === "settings" && <SettingsTab workspace={workspace} />}
{activeTab === "files" && (
<WorkspaceFilePanel workspaceId={workspace.id} />
)}
{activeTab === "git" && (
<WorkspaceGitPanel workspaceId={workspace.id} />
)}
{activeTab === "tools" && (
<WorkspaceToolsPanel workspace={workspace} />
)}
{activeTab === "settings" && (
<WorkspaceSettingsPanel workspace={workspace} />
)}
</div>
{isMobile && <MobileTabBar active={activeTab} onChange={setActiveTab} />}
</div>
);
}
function WorkspaceHeader({
workspace,
}: {
workspace: {
name: string;
repo_name: string;
project_name: string;
branch: string;
};
}) {
return (
<header className="workspace-header">
<div className="workspace-breadcrumb">
<span>{workspace.project_name}</span>
<span className="sep">/</span>
<span>{workspace.repo_name}</span>
<span className="sep">/</span>
<strong>{workspace.name}</strong>
</div>
<div className="workspace-actions">
<span className="branch-badge">
<Icon name="branch" size="sm" /> {workspace.branch}
</span>
</div>
</header>
);
}
function TabBar({
active,
onChange,
}: {
active: Tab;
onChange: (t: Tab) => void;
}) {
const tabs: { id: Tab; label: string; icon: string }[] = [
{ id: "files", label: "Files", icon: "folder" },
{ id: "git", label: "Git", icon: "branch" },
{ id: "tools", label: "Tools", icon: "terminal" },
{ id: "settings", label: "Settings", icon: "settings" },
];
return (
<nav className="tab-bar" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
className={`tab ${active === tab.id ? "active" : ""}`}
onClick={() => onChange(tab.id)}
role="tab"
aria-selected={active === tab.id}
>
<Icon
name={tab.icon as "folder" | "branch" | "terminal" | "settings"}
size="sm"
/>
{tab.label}
</button>
))}
</nav>
);
}
function MobileTabBar({
active,
onChange,
}: {
active: Tab;
onChange: (t: Tab) => void;
}) {
const tabs: { id: Tab; label: string; icon: string }[] = [
{ id: "files", label: "Files", icon: "folder" },
{ id: "git", label: "Git", icon: "branch" },
{ id: "tools", label: "Tools", icon: "terminal" },
{ id: "settings", label: "Settings", icon: "settings" },
];
return (
<nav className="mobile-tab-bar" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
className={`mobile-tab ${active === tab.id ? "active" : ""}`}
onClick={() => onChange(tab.id)}
role="tab"
aria-selected={active === tab.id}
>
<Icon
name={tab.icon as "folder" | "branch" | "terminal" | "settings"}
/>
<span>{tab.label}</span>
</button>
))}
</nav>
);
}
/* ─── Files Tab ─── */
function FilesTab({ workspaceId }: { workspaceId: string }) {
const {
entries,
content,
currentPath,
loadFile,
saveFile,
loading,
error,
navigateTo,
} = useWorkspaceFiles(workspaceId);
const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [editContent, setEditContent] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [commitMessage, setCommitMessage] = useState("");
const handleSelect = (entry: FileEntry) => {
if (entry.type === "directory") {
setSelectedPath(null);
setIsEditing(false);
setEditContent(null);
navigateTo(entry.path);
return;
}
setSelectedPath(entry.path);
setIsEditing(false);
setEditContent(null);
loadFile(entry.path);
};
const navigateUp = () => {
if (!currentPath) return;
const parentPath = currentPath.split("/").slice(0, -1).join("/");
navigateTo(parentPath);
setSelectedPath(null);
setIsEditing(false);
setEditContent(null);
};
const handleEdit = () => {
if (content !== null) {
setEditContent(content);
setIsEditing(true);
}
};
const handleSave = async () => {
if (selectedPath && editContent !== null) {
await saveFile(selectedPath, editContent, commitMessage || undefined);
setIsEditing(false);
setCommitMessage("");
}
};
return (
<div className="files-tab">
{status && (
<div className="git-toolbar">
<div className="git-toolbar-status">
{status.modified.length > 0 && (
<span className="status-modified">
M {status.modified.length}
</span>
)}
{status.added.length > 0 && (
<span className="status-added">A {status.added.length}</span>
)}
{status.deleted.length > 0 && (
<span className="status-deleted">D {status.deleted.length}</span>
)}
{status.untracked.length > 0 && (
<span className="status-untracked">
? {status.untracked.length}
</span>
)}
</div>
<div className="git-toolbar-actions">
<input
type="text"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Commit message"
/>
<button
onClick={() => commit(commitMessage)}
disabled={!commitMessage}
>
Commit
</button>
<button onClick={push}>Push</button>
<button onClick={pull}>Pull</button>
<button onClick={fetch}>Fetch</button>
</div>
</div>
)}
<div className="files-split">
<div className="file-tree">
{currentPath && (
<button
className="tree-entry tree-up"
onClick={navigateUp}
type="button"
>
<Icon name="folder" size="sm" /> ..
</button>
)}
{loading && <p className="muted">Loading...</p>}
{error && <p className="error-text">{error}</p>}
{entries.map((entry) => (
<button
key={entry.path}
className={`tree-entry ${entry.type} ${selectedPath === entry.path ? "selected" : ""}`}
onClick={() => handleSelect(entry)}
type="button"
>
<Icon
name={entry.type === "directory" ? "folder" : "file"}
size="sm"
/>
{entry.name}
</button>
))}
</div>
<div className="file-viewer">
{selectedPath ? (
<>
<div className="file-viewer-header">
<span>{selectedPath}</span>
{!isEditing && <button onClick={handleEdit}>Edit</button>}
</div>
{isEditing ? (
<>
<textarea
className="file-editor"
value={editContent || ""}
onChange={(e) => setEditContent(e.target.value)}
/>
<div className="file-editor-actions">
<button onClick={() => setIsEditing(false)}>Cancel</button>
<button onClick={handleSave}>Save</button>
</div>
</>
) : (
<pre className="file-content">{content || "Loading..."}</pre>
)}
</>
) : (
<p className="muted">Select a file to view</p>
)}
</div>
</div>
</div>
);
}
/* ─── Git Tab ─── */
function GitTab({ workspaceId }: { workspaceId: string }) {
const { history, branches, currentBranch, checkout, loading, error } =
useWorkspaceGit(workspaceId);
return (
<div className="git-tab">
<div className="git-tab-header">
<select
value={currentBranch}
onChange={(e) => checkout(e.target.value)}
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
{loading && <p className="muted">Loading history...</p>}
{error && <p className="error-text">{error}</p>}
<div className="commit-history">
{history.map((commit) => (
<div key={commit.hash} className="commit-row">
<span className="commit-hash">{commit.hash.slice(0, 7)}</span>
<span className="commit-message">{commit.message}</span>
<span className="commit-author">{commit.author}</span>
<span className="commit-date">{commit.date}</span>
</div>
))}
</div>
</div>
);
}
/* ─── Tools Tab ─── */
function ToolsTab({ workspace }: { workspace: Workspace }) {
const { instances, loading, refresh } = useWorkspaceInstances(workspace.id);
const [showModal, setShowModal] = useState(false);
return (
<div className="tools-tab">
{loading && <p className="muted">Loading instances...</p>}
{instances.length === 0 ? (
<div className="empty-state-card">
<Icon name="terminal" size="lg" />
<h3>No tools running</h3>
<p>Start a tool to begin coding in this workspace</p>
<button
className="btn btn-primary"
onClick={() => setShowModal(true)}
>
Start Tool
</button>
</div>
) : (
<>
<div className="instances-grid">
{instances.map((instance) => (
<div
key={instance.id}
className={`instance-card ${instance.status}`}
>
<h4>{instance.display_name}</h4>
<span className="status-badge">{instance.status}</span>
{instance.url && (
<a
href={instance.url}
target={`instance-${instance.id}`}
rel="noreferrer"
>
Open
</a>
)}
</div>
))}
</div>
<button
className="btn btn-primary"
onClick={() => setShowModal(true)}
>
Start Another Tool
</button>
</>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Start Tool</h3>
<ToolStarter
workspace={workspace}
onStarted={() => {
setShowModal(false);
void refresh();
}}
onCancel={() => setShowModal(false)}
/>
</div>
</div>
{isMobile && (
<WorkspaceMobileTabBar active={activeTab} onChange={setActiveTab} />
)}
</div>
);
}
/* ─── Settings Tab ─── */
function SettingsTab({ workspace }: { workspace: Workspace }) {
return (
<div className="settings-tab">
<div className="settings-section">
<h3>Workspace Info</h3>
<div className="form-group">
<label>Name</label>
<input type="text" value={workspace.name} readOnly />
</div>
<div className="form-group">
<label>Branch</label>
<input type="text" value={workspace.branch} readOnly />
</div>
<div className="form-group">
<label>Path</label>
<input type="text" value={workspace.path} readOnly />
</div>
<div className="form-group">
<label>Status</label>
<span className={`status-badge ${workspace.status}`}>
{workspace.status}
</span>
</div>
<div className="form-group">
<label>Created</label>
<span>{workspace.created_at}</span>
</div>
</div>
</div>
);
}