feat: complete workspace-first-ui cleanup and tests

- Delete dead repo-workspace code: RepoWorkspacePage, useRepoWorkspace,
  WorkspaceLayout, FileBrowser, old git components (git-toolbar, file-editor,
  commit-panel), and repo-workspace.css.
- Fix stale backend test imports for moved models/services.
- Add GitOperations unit tests.
- Add integration tests for workspace files, git, and instances endpoints.
- Add frontend tests for WorkspaceDetailPage and ProjectCard.
- Update OpenSpec workspace-first-ui tasks and mark change completed.
- Regenerate project maps.

Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed),
python3 -m py_compile on changed backend files, pytest backend workspace tests.
This commit is contained in:
Developer
2026-06-12 17:25:09 +00:00
parent aa49efb236
commit c26e9eacfa
49 changed files with 993 additions and 2057 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ dir: .
Trust boundary: index routes, map orients, source decides.
## role
Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing Docker orchestration, environment templates, and development tooling.
Infrastructure and deployment configuration package for a self-hosted project management platform with containerized services, reverse proxy support, and AI agent workflow definitions.
## parent
-
## children
+2 -2
View File
@@ -18,7 +18,7 @@ index: ./.pi-map.index.md
Trust boundary: index routes, map orients, source decides.
## role
Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing Docker orchestration, environment templates, and development tooling.
Infrastructure and deployment configuration package for a self-hosted project management platform with containerized services, reverse proxy support, and AI agent workflow definitions.
## files
- .env.example | Provides a template of environment variables for configuring a Headquarter application with PostgreSQL, Redis, Authentik SSO, and Docker/Traefik deployment
- .gitignore | Specifies files and directories for Git to ignore across a multi-language project with Python, Node, and custom tooling | dep: Git
@@ -31,7 +31,7 @@ Infrastructure and deployment configuration package for a self-hosted project ma
- progress.md | Tracks completed and remaining tasks for a backend-frontend code refactoring project organized in 7 phases
- swap-pane | Empty file with no functionality
## arch
Containerized microservices architecture using Docker Compose with PostgreSQL/Redis data layer, Traefik reverse proxy for TLS/ingress, and environment-driven configuration; includes Python/Node multi-language backend-frontend split with Makefile-driven lifecycle management.
Docker Compose-based microservices architecture with PostgreSQL/Redis backend, separate API/frontend containers, Traefik reverse proxy integration, environment-driven configuration, and OpenSpec-driven AI agent orchestration.
## tags
docker, redis, git, application, postgresql, compose, traefik, project
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps
## role
Contains the top-level application entry points and executable binaries for the project.
Contains the main deployable application modules that compose the complete system.
## 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 top-level application entry points and executable binaries for the project.
Contains the main deployable application modules that compose the complete system.
## files
## arch
Follows a workspace/monorepo pattern where each subdirectory is a distinct deployable application sharing common libraries.
Modular application architecture with separate entry points for different application contexts or deployment targets.
## tags
-
## symbols
+7 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api
## role
Backend API server providing project management, git repository operations, and development tool orchestration via Docker containers.
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
## parent
index: apps/.pi-map.index.md
map: apps/.pi-map.md
@@ -10,6 +10,12 @@ map: apps/.pi-map.md
- apps/api/.pi-lens
index: apps/api/.pi-lens/.pi-map.index.md
map: apps/api/.pi-lens/.pi-map.md
- apps/api/.pytest_cache
index: apps/api/.pytest_cache/.pi-map.index.md
map: apps/api/.pytest_cache/.pi-map.md
- apps/api/.venv-test
index: apps/api/.venv-test/.pi-map.index.md
map: apps/api/.venv-test/.pi-map.md
- apps/api/alembic
index: apps/api/alembic/.pi-map.index.md
map: apps/api/alembic/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/api
index: apps/api/.pi-map.index.md
## role
Backend API server providing project management, git repository operations, and development tool orchestration via Docker containers.
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
## 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 @@ Backend API server providing project management, git repository operations, and
- 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
FastAPI-based async Python service with multi-stage Docker deployment, PostgreSQL database with Alembic migrations, Cloudflare tunnel integration, and uv package management.
Multi-stage Dockerized Python application using async FastAPI, PostgreSQL with Alembic migrations, uv package management, and Cloudflare tunneling with external Docker socket access.
## tags
docker, alembic, python, database, fastapi, postgresql, asyncpg, uvicorn
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/tests
## role
Shared testing infrastructure and fixtures for the FastAPI API application
Provides shared test infrastructure and fixtures for the FastAPI API application.
## parent
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: apps/api/tests
index: apps/api/tests/.pi-map.index.md
## role
Shared testing infrastructure and fixtures for the FastAPI API application
Provides shared test infrastructure and fixtures for the FastAPI API application.
## files
- conftest.py | Provides shared pytest fixtures for FastAPI testing with async SQLite database, authenticated clients, and test data setup. | exp: func:test_client() → Generator[TestClient, None, None], call:create_async_engine, call:engine.begin, call:conn.run_sync, call:asyncio.run, call:init_db, call:async_sessionmaker, call:patch, call:TestClient, call:app.dependency_overrides.pop, call:engine.dispose, func:init_db(), call:engine.begin, call:conn.run_sync, func:override_get_db_session() → AsyncGenerator[AsyncSession, None], call:async_sessionmaker, func:db_session(test_client) → AsyncGenerator[AsyncSession, None], call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:gen.aclose, call:create_async_engine, call:engine.begin, call:conn.run_sync, call:async_sessionmaker, call:engine.dispose, func:authenticated_client(test_client) → Generator[TestClient, None, None], call:str, call:uuid.uuid4, call:Settings, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, call:asyncio.run, call:create_test_user, call:create_session_cookie, call:test_client.cookies.set, func:create_test_user(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, func:test_project_and_repo(authenticated_client) → tuple[str, str], call:uuid.uuid4, call:Settings, call:authenticated_client.cookies.get, call:decode_session_cookie, call:uuid.UUID, call:asyncio.run, call:get_user_id, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:Project, call:session.add, call:GitRepository, call:session.commit, call:gen.aclose, call:create_project_and_repo, call:str, raise:RuntimeError, func:get_user_id(), call:Settings, call:authenticated_client.cookies.get, call:decode_session_cookie, call:uuid.UUID, func:create_project_and_repo(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:Project, call:session.add, call:GitRepository, call:session.commit, call:gen.aclose, func:admin_client(test_client) → Generator[TestClient, None, None], call:str, call:uuid.uuid4, call:Settings, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, call:asyncio.run, call:create_admin_user, call:create_session_cookie, call:test_client.cookies.set, func:create_admin_user(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose | dep: asyncio, os, typing, unittest.mock, pytest, pytest_asyncio, fastapi.testclient, sqlalchemy.ext.asyncio, src.config, src.models.base, src.main, src.auth.dependencies, uuid, src.auth.session, src.models.user, src.models.project, src.models.git_repository, fastapi, sqlalchemy, src.models, src.auth
## arch
Pytest fixture-based dependency injection pattern with async SQLite test database, authenticated HTTP client factories, and modular test data setup utilities
Pytest fixture-based testing architecture with async SQLite database setup, authenticated HTTP client factories, and test data seeding patterns.
## tags
call:app.dependency, call:create, overrides.get, call:override, fn, call:gen.asend, call:gen.aclose, session
## symbols
+4 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/tests/integration
## role
Integration and unit test suite for the API backend, covering authentication, REST endpoints, database models, and core services with real database dependencies.
Integration and unit test suite for the FastAPI backend API, covering authentication, CRUD APIs, workspace/git operations, and event/notification systems using real databases and external dependencies.
## parent
index: apps/api/tests/.pi-map.index.md
map: apps/api/tests/.pi-map.md
@@ -24,6 +24,9 @@ map: apps/api/tests/.pi-map.md
- test_tool_types_api.py
- test_tool_types_api_extended.py
- test_users_api.py
- test_workspace_files.py
- test_workspace_git.py
- test_workspace_instances.py
- test_workspaces_api.py
## links
index: apps/api/tests/integration/.pi-map.index.md
+10 -7
View File
@@ -4,7 +4,7 @@ dir: apps/api/tests/integration
index: apps/api/tests/integration/.pi-map.index.md
## role
Integration and unit test suite for the API backend, covering authentication, REST endpoints, database models, and core services with real database dependencies.
Integration and unit test suite for the FastAPI backend API, covering authentication, CRUD APIs, workspace/git operations, and event/notification systems using real databases and external dependencies.
## files
- __init__.py | Empty file with no functionality
- test_auth_api.py | Integration tests for FastAPI authentication endpoints (login, me, logout) using a real database and session cookies. | exp: func:_prepare_auth_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:importlib.reload, func:_insert_test_user(user_id: str) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_login_redirects_to_authentik_authorize_endpoint() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.get, func:test_me_returns_401_without_session_cookie() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.get, func:test_me_returns_user_with_valid_session() → None, call:_prepare_auth_test_db, call:_insert_test_user, call:_load_app, call:Settings, call:create_session_cookie, call:TestClient, call:client.get, call:response.json, func:test_logout_clears_session_cookie() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.post, call:response.headers.get | dep: uuid, asyncio, importlib, fastapi.testclient, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.user, fastapi, src.database, src.api.auth, src.main
@@ -21,20 +21,23 @@ Integration and unit test suite for the API backend, covering authentication, RE
- test_tool_types_api.py | Integration tests for a FastAPI tool types REST API endpoint covering CRUD operations, authentication, and validation rules | exp: func:_prepare_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:hasattr, call:asyncio.run, call:database_module.engine.dispose, call:importlib.reload, func:_mint_token(user_id: str) → str, call:Settings, call:create_session_cookie, call:datetime.now, call:timedelta, func:_insert_user(user_id: str, email) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_insert_tool_type(tool_type_id: str, name: str, display_name: str, compose_template: str, created_by_id) → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:ToolType, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:ToolType, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_list_tool_types_requires_authentication() → None, call:_prepare_test_db, call:_load_app, call:TestClient, call:client.get, func:test_list_tool_types_returns_all_types() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, call:len, call:next, func:test_get_tool_type_by_id() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, func:test_get_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, func:test_create_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, call:response.json, func:test_create_tool_type_duplicate_name() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_invalid_yaml() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_missing_services() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_missing_required_variable() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_update_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.put, call:response.json, func:test_update_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.put, func:test_delete_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete, call:client.get, func:test_delete_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete | dep: uuid, datetime, asyncio, pytest, fastapi.testclient, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.tool_type, src.models.user, importlib, fastapi, src.database, src.api.auth, src.api.tool_types, src.main
- test_tool_types_api_extended.py | Integration tests for a FastAPI tool types API endpoint covering CRUD operations with extended fields including dockerfile templates, compose templates, readiness probes, startup commands, and validation logic. | exp: class:TestToolTypesAPIExtended, method:test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) → None, call:authenticated_client.post, method:test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) → None, call:authenticated_client.post, method:test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.put, call:response.json, method:test_validate_tool_type_compose(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.get, call:response.json, method:test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, call:str, method:test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_update_tool_type_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.put, call:response.json, method:test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.get, call:response.json, call:str | dep: pytest, fastapi.testclient, fastapi.testclient.TestClient
- test_users_api.py | Integration tests for user profile API endpoints including get, update, and avatar upload with authentication and validation | exp: func:_prepare_users_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:importlib.reload, func:_insert_test_user(user_id: str) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_create_auth_cookie(user_id: str) → str, call:Settings, call:create_session_cookie, call:datetime.now, call:timedelta, func:test_get_profile_returns_401_without_cookie() → None, call:_prepare_users_test_db, call:_load_app, call:TestClient, call:client.get, func:test_get_profile_returns_user_data() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.get, call:response.json, func:test_update_profile_changes_name_and_email() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, call:response.json, func:test_update_profile_rejects_empty_name() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, func:test_update_profile_rejects_invalid_email() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, func:test_upload_avatar_updates_avatar_url() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO, call:response.json, call:data["avatar_url"].startswith, func:test_upload_avatar_rejects_invalid_file_type() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO, func:test_upload_avatar_rejects_oversized_file() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO | dep: uuid, datetime, asyncio, io, fastapi.testclient, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.user, importlib, fastapi, src.database, src.api.users, src.main
- test_workspaces_api.py | Integration tests for FastAPI workspace API endpoints covering CRUD, sync, and deletion operations with mocked services. | exp: class:TestListWorkspaces, method:test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository), call:authenticated_client.get, call:response.json, method:test_list_with_workspaces(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_commit, call:authenticated_client.get, call:response.json, call:len, class:TestCreateWorkspace, method:test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository), call:Workspace, call:uuid.uuid4, call:patch.object, call:authenticated_client.post, call:response.json, call:mock_create.assert_called_once, method:test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository), call:authenticated_client.post, call:response.json, method:test_create_duplicate_name(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_commit, call:patch.object, call:Exception, call:authenticated_client.post, class:TestDeleteWorkspace, method:test_delete_without_instances(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:authenticated_client.delete, call:response.json, method:test_delete_with_instances_force(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:authenticated_client.delete, class:TestSyncWorkspace, method:test_sync_success(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:MagicMock, call:authenticated_client.post, call:response.json, method:test_sync_branch_deleted(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:MagicMock, call:authenticated_client.post, call:response.json, func:_get_user_id_from_client(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_repo(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id_from_client, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:db_session.commit, call:db_session.refresh | dep: asyncio, uuid, unittest.mock, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models.git_repository, src.models.project, src.models.tool_instance, src.models.tool_type, src.models.workspace, src.services.workspace_manager, src.auth.session, src.config, fastapi, sqlalchemy, src.models
- test_workspace_files.py | Integration tests for workspace file endpoints (list, read, write) with path traversal security checks | exp: class:TestListWorkspaceFiles, method:test_list_files(self, authenticated_client: TestClient, test_repo_with_workspace: Workspace), call:os.makedirs, call:os.path.join, call:open, call:f.write, call:authenticated_client.get, call:response.json, method:test_list_files_path_escapes_workspace(self, authenticated_client: TestClient, test_repo_with_workspace: Workspace), call:authenticated_client.get, class:TestGetFileContent, method:test_get_content(self, authenticated_client: TestClient, test_repo_with_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:authenticated_client.get, call:response.json, method:test_get_missing_file(self, authenticated_client: TestClient, test_repo_with_workspace: Workspace), call:authenticated_client.get, class:TestWriteFileContent, method:test_write_file(self, authenticated_client: TestClient, test_repo_with_workspace: Workspace), call:authenticated_client.post, call:os.path.exists, call:os.path.join, method:test_write_file_missing_path(self, authenticated_client: TestClient, test_repo_with_workspace: Workspace), call:authenticated_client.post, func:_get_user_id(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_repo_with_workspace(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:Workspace, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:Workspace, call:db_session.commit, call:db_session.refresh | dep: asyncio, os, tempfile, uuid, unittest.mock, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models, src.services.shared.workspace_manager, src.auth.session, src.config, fastapi.testclient.TestClient, sqlalchemy.ext.asyncio.AsyncSession
- test_workspace_git.py | Integration tests for workspace Git endpoints (status, commit, history, checkout) using real git repositories and FastAPI test client. | exp: class:TestGitStatus, method:test_status_clean(self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace), call:authenticated_client.get, call:response.json, method:test_status_modified(self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:authenticated_client.get, call:response.json, class:TestGitCommit, method:test_commit(self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:authenticated_client.post, call:response.json, method:test_commit_missing_message(self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace), call:authenticated_client.post, class:TestGitHistory, method:test_history(self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace), call:authenticated_client.get, call:response.json, call:len, class:TestGitCheckout, method:test_checkout(self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace), call:_run_git, call:authenticated_client.post, call:response.json, func:_run_git(*args: str, cwd: str) → None, call:subprocess.run, func:_get_user_id(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_repo_with_git_workspace(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:_run_git, call:open, call:os.path.join, call:f.write, call:Workspace, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:_run_git, call:open, call:os.path.join, call:f.write, call:Workspace, call:db_session.commit, call:db_session.refresh | dep: asyncio, os, subprocess, tempfile, uuid, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models, src.auth.session, src.config, fastapi.testclient.TestClient, sqlalchemy.ext.asyncio.AsyncSession
- test_workspace_instances.py | Integration tests for workspace instance REST API endpoints (listing and creating tool instances within workspaces) | exp: class:TestListWorkspaceInstances, method:test_list_empty(self, authenticated_client: TestClient, test_workspace_with_tool_type), call:authenticated_client.get, call:response.json, method:test_list_instances(self, authenticated_client: TestClient, db_session: AsyncSession, test_workspace_with_tool_type), call:ToolInstance, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_create_instance, call:authenticated_client.get, call:response.json, call:len, class:TestCreateWorkspaceInstance, method:test_create_instance(self, authenticated_client: TestClient, test_workspace_with_tool_type), call:ToolInstance, call:uuid.uuid4, call:datetime.now, call:patch, call:authenticated_client.post, call:str, call:response.json, func:_get_user_id(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_workspace_with_tool_type(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:Workspace, call:ToolType, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:Workspace, call:ToolType, call:db_session.commit, call:db_session.refresh | dep: asyncio, tempfile, uuid, datetime, unittest.mock, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models, src.auth.session, src.config, src.api.workspace.workspace_instances
- test_workspaces_api.py | Integration tests for workspace API endpoints including list, create, delete, and sync operations with repository/project scoping | exp: class:TestListWorkspaces, method:test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository), call:authenticated_client.get, call:response.json, method:test_list_with_workspaces(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_commit, call:authenticated_client.get, call:response.json, call:len, class:TestCreateWorkspace, method:test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository), call:Workspace, call:uuid.uuid4, call:patch.object, call:authenticated_client.post, call:response.json, call:mock_create.assert_called_once, method:test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository), call:authenticated_client.post, call:response.json, method:test_create_duplicate_name(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_commit, call:patch.object, call:Exception, call:authenticated_client.post, class:TestDeleteWorkspace, method:test_delete_without_instances(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:authenticated_client.delete, call:response.json, method:test_delete_with_instances_force(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:authenticated_client.delete, class:TestSyncWorkspace, method:test_sync_success(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:MagicMock, call:authenticated_client.post, call:response.json, method:test_sync_branch_deleted(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:MagicMock, call:authenticated_client.post, call:response.json, func:_get_user_id_from_client(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_repo(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id_from_client, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:db_session.commit, call:db_session.refresh | dep: asyncio, uuid, unittest.mock, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models, src.services.shared.workspace_manager, src.auth.session, src.config, fastapi, sqlalchemy
## arch
Test pyramid with integration tests dominating; uses FastAPI TestClient, real SQLAlchemy database sessions, session cookie authentication, pub/sub event bus mocking, and service-layer mocking for external dependencies (git, SSH, workspaces).
Test-driven pyramid with integration tests as primary layer using FastAPI TestClient, real SQLAlchemy databases, actual git repositories, and pub/sub event bus; unit tests limited to isolated git utilities; shared fixtures likely in conftest.py for dependency injection, database setup/teardown, and authenticated client sessions.
## tags
test, call:, call:authenticated, user, call:db, call:create, create, call:response.json
test, call:, call:db, call:authenticated, user, call:response.json, create, call:create
## symbols
- TestConfigProfilesAPI
- TestGitStatus
- TestBranchOperations
- TestCommit
- TestToolTypesAPIExtended
- TestListWorkspaces
- TestCreateWorkspace
- TestDeleteWorkspace
- TestListWorkspaceFiles
- TestGetFileContent
- TestWriteFileContent
## workflows
- change integration behavior
read: __init__.py, test_auth_api.py, test_auth_services.py
@@ -0,0 +1,142 @@
"""Integration tests for workspace file endpoints."""
import asyncio
import os
import tempfile
import uuid
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import GitRepository, Project, Workspace
from src.services.shared.workspace_manager import WorkspaceManager
def _get_user_id(client: TestClient) -> uuid.UUID:
from src.auth.session import decode_session_cookie
from src.config import Settings
settings = Settings()
cookie = client.cookies.get("session")
data = decode_session_cookie(settings=settings, cookie_value=cookie)
if not data:
raise RuntimeError("No session")
return uuid.UUID(data["user_id"])
@pytest.fixture
def test_repo_with_workspace(
db_session: AsyncSession, authenticated_client: TestClient
):
"""Create a project, repo, and workspace backed by a temp directory."""
user_id = _get_user_id(authenticated_client)
async def _create():
project = Project(name="File Test Project", owner_id=user_id)
db_session.add(project)
await db_session.flush()
repo = GitRepository(
name="file-test-repo",
path="/tmp/file-test-repo",
remote_url="https://example.com/repo.git",
project_id=project.id,
owner_id=user_id,
)
db_session.add(repo)
await db_session.flush()
tmpdir = tempfile.mkdtemp()
ws = Workspace(
name="dev",
repo_id=repo.id,
user_id=user_id,
branch="main",
path=tmpdir,
)
db_session.add(ws)
await db_session.commit()
await db_session.refresh(ws)
return ws
return asyncio.run(_create())
class TestListWorkspaceFiles:
"""Tests for GET /workspaces/{id}/files."""
def test_list_files(
self, authenticated_client: TestClient, test_repo_with_workspace: Workspace
):
ws = test_repo_with_workspace
os.makedirs(os.path.join(ws.path, "src"))
with open(os.path.join(ws.path, "README.md"), "w") as f:
f.write("# Test")
response = authenticated_client.get(f"/workspaces/{ws.id}/files")
assert response.status_code == 200
data = response.json()
names = {e["name"] for e in data["entries"]}
assert "src" in names
assert "README.md" in names
def test_list_files_path_escapes_workspace(
self, authenticated_client: TestClient, test_repo_with_workspace: Workspace
):
response = authenticated_client.get(
f"/workspaces/{test_repo_with_workspace.id}/files", params={"path": "../outside"}
)
assert response.status_code == 400
class TestGetFileContent:
"""Tests for GET /workspaces/{id}/files/content."""
def test_get_content(
self, authenticated_client: TestClient, test_repo_with_workspace: Workspace
):
with open(os.path.join(test_repo_with_workspace.path, "hello.py"), "w") as f:
f.write("print('hello')")
response = authenticated_client.get(
f"/workspaces/{test_repo_with_workspace.id}/files/content",
params={"path": "hello.py"},
)
assert response.status_code == 200
assert response.json()["content"] == "print('hello')"
def test_get_missing_file(
self, authenticated_client: TestClient, test_repo_with_workspace: Workspace
):
response = authenticated_client.get(
f"/workspaces/{test_repo_with_workspace.id}/files/content",
params={"path": "missing.txt"},
)
assert response.status_code == 404
class TestWriteFileContent:
"""Tests for POST /workspaces/{id}/files/content."""
def test_write_file(
self, authenticated_client: TestClient, test_repo_with_workspace: Workspace
):
response = authenticated_client.post(
f"/workspaces/{test_repo_with_workspace.id}/files/content",
json={"path": "nested/file.txt", "content": "content"},
)
assert response.status_code == 200
assert os.path.exists(
os.path.join(test_repo_with_workspace.path, "nested", "file.txt")
)
def test_write_file_missing_path(
self, authenticated_client: TestClient, test_repo_with_workspace: Workspace
):
response = authenticated_client.post(
f"/workspaces/{test_repo_with_workspace.id}/files/content",
json={"content": "content"},
)
assert response.status_code == 400
@@ -0,0 +1,160 @@
"""Integration tests for workspace git endpoints."""
import asyncio
import os
import subprocess
import tempfile
import uuid
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import GitRepository, Project, Workspace
def _run_git(*args: str, cwd: str) -> None:
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
def _get_user_id(client: TestClient) -> uuid.UUID:
from src.auth.session import decode_session_cookie
from src.config import Settings
settings = Settings()
cookie = client.cookies.get("session")
data = decode_session_cookie(settings=settings, cookie_value=cookie)
if not data:
raise RuntimeError("No session")
return uuid.UUID(data["user_id"])
@pytest.fixture
def test_repo_with_git_workspace(
db_session: AsyncSession, authenticated_client: TestClient
):
"""Create a workspace backed by a real git repo with commits."""
user_id = _get_user_id(authenticated_client)
async def _create():
project = Project(name="Git Test Project", owner_id=user_id)
db_session.add(project)
await db_session.flush()
repo = GitRepository(
name="git-test-repo",
path="/tmp/git-test-repo",
remote_url="https://example.com/repo.git",
project_id=project.id,
owner_id=user_id,
)
db_session.add(repo)
await db_session.flush()
tmpdir = tempfile.mkdtemp()
_run_git("init", cwd=tmpdir)
_run_git("config", "user.email", "test@test.com", cwd=tmpdir)
_run_git("config", "user.name", "Test User", cwd=tmpdir)
with open(os.path.join(tmpdir, "README.md"), "w") as f:
f.write("# Test\n")
_run_git("add", "README.md", cwd=tmpdir)
_run_git("commit", "-m", "Initial commit", cwd=tmpdir)
ws = Workspace(
name="dev",
repo_id=repo.id,
user_id=user_id,
branch="main",
path=tmpdir,
)
db_session.add(ws)
await db_session.commit()
await db_session.refresh(ws)
return ws
return asyncio.run(_create())
class TestGitStatus:
"""Tests for GET /workspaces/{id}/git/status."""
def test_status_clean(
self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace
):
response = authenticated_client.get(
f"/workspaces/{test_repo_with_git_workspace.id}/git/status"
)
assert response.status_code == 200
data = response.json()
assert data["branch"] == "main"
assert data["modified"] == []
def test_status_modified(
self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace
):
with open(os.path.join(test_repo_with_git_workspace.path, "README.md"), "a") as f:
f.write("modified\n")
response = authenticated_client.get(
f"/workspaces/{test_repo_with_git_workspace.id}/git/status"
)
assert response.status_code == 200
assert "README.md" in response.json()["modified"]
class TestGitCommit:
"""Tests for POST /workspaces/{id}/git/commit."""
def test_commit(
self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace
):
with open(os.path.join(test_repo_with_git_workspace.path, "new.py"), "w") as f:
f.write("print('hello')\n")
response = authenticated_client.post(
f"/workspaces/{test_repo_with_git_workspace.id}/git/commit",
json={"message": "Add new file"},
)
assert response.status_code == 200
assert response.json()["status"] == "committed"
def test_commit_missing_message(
self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace
):
response = authenticated_client.post(
f"/workspaces/{test_repo_with_git_workspace.id}/git/commit",
json={},
)
assert response.status_code == 400
class TestGitHistory:
"""Tests for GET /workspaces/{id}/git/history."""
def test_history(
self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace
):
response = authenticated_client.get(
f"/workspaces/{test_repo_with_git_workspace.id}/git/history"
)
assert response.status_code == 200
data = response.json()
assert len(data["commits"]) >= 1
assert data["commits"][0]["message"] == "Initial commit"
class TestGitCheckout:
"""Tests for POST /workspaces/{id}/git/checkout."""
def test_checkout(
self, authenticated_client: TestClient, test_repo_with_git_workspace: Workspace
):
_run_git("checkout", "-b", "feature", cwd=test_repo_with_git_workspace.path)
_run_git("checkout", "main", cwd=test_repo_with_git_workspace.path)
response = authenticated_client.post(
f"/workspaces/{test_repo_with_git_workspace.id}/git/checkout",
json={"branch": "feature"},
)
assert response.status_code == 200
assert response.json()["branch"] == "feature"
@@ -0,0 +1,151 @@
"""Integration tests for workspace instance endpoints."""
import asyncio
import tempfile
import uuid
from datetime import datetime
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import GitRepository, Project, ToolInstance, ToolType, Workspace
def _get_user_id(client: TestClient) -> uuid.UUID:
from src.auth.session import decode_session_cookie
from src.config import Settings
settings = Settings()
cookie = client.cookies.get("session")
data = decode_session_cookie(settings=settings, cookie_value=cookie)
if not data:
raise RuntimeError("No session")
return uuid.UUID(data["user_id"])
@pytest.fixture
def test_workspace_with_tool_type(
db_session: AsyncSession, authenticated_client: TestClient
):
"""Create a project, repo, workspace, and tool type."""
user_id = _get_user_id(authenticated_client)
async def _create():
project = Project(name="Instance Test Project", owner_id=user_id)
db_session.add(project)
await db_session.flush()
repo = GitRepository(
name="instance-test-repo",
path="/tmp/instance-test-repo",
remote_url="https://example.com/repo.git",
project_id=project.id,
owner_id=user_id,
)
db_session.add(repo)
await db_session.flush()
tmpdir = tempfile.mkdtemp()
ws = Workspace(
name="dev",
repo_id=repo.id,
user_id=user_id,
branch="main",
path=tmpdir,
)
db_session.add(ws)
await db_session.flush()
tool_type = ToolType(
name="test-tool",
display_name="Test Tool",
default_port=8080,
category="dev",
)
db_session.add(tool_type)
await db_session.commit()
await db_session.refresh(ws)
await db_session.refresh(tool_type)
return ws, tool_type, project
return asyncio.run(_create())
class TestListWorkspaceInstances:
"""Tests for GET /workspaces/{id}/instances."""
def test_list_empty(
self, authenticated_client: TestClient, test_workspace_with_tool_type
):
ws, _, _ = test_workspace_with_tool_type
response = authenticated_client.get(f"/workspaces/{ws.id}/instances")
assert response.status_code == 200
assert response.json() == []
def test_list_instances(
self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_workspace_with_tool_type,
):
ws, tool_type, project = test_workspace_with_tool_type
async def _create_instance():
instance = ToolInstance(
name="test-instance",
display_name="Test Instance",
tool_type_id=tool_type.id,
repository_id=ws.repo_id,
project_id=project.id,
owner_id=ws.user_id,
workspace_id=ws.id,
status="running",
)
db_session.add(instance)
await db_session.commit()
asyncio.run(_create_instance())
response = authenticated_client.get(f"/workspaces/{ws.id}/instances")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["display_name"] == "Test Instance"
class TestCreateWorkspaceInstance:
"""Tests for POST /workspaces/{id}/instances."""
def test_create_instance(
self,
authenticated_client: TestClient,
test_workspace_with_tool_type,
):
ws, tool_type, project = test_workspace_with_tool_type
mock_instance = ToolInstance(
id=uuid.uuid4(),
name="mock-instance",
display_name="Mock Instance",
tool_type_id=tool_type.id,
repository_id=ws.repo_id,
project_id=project.id,
owner_id=ws.user_id,
workspace_id=ws.id,
status="pending",
created_at=datetime.now(),
)
with patch(
"src.api.workspace.workspace_instances.create_tool_instance",
return_value=mock_instance,
):
response = authenticated_client.post(
f"/workspaces/{ws.id}/instances",
json={"tool_type_id": str(tool_type.id), "display_name": "New Instance"},
)
assert response.status_code == 201
data = response.json()
assert data["display_name"] == "Mock Instance"
assert data["status"] == "pending"
@@ -8,12 +8,14 @@ import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models.workspace import Workspace
from src.services.workspace_manager import WorkspaceManager
from src.models import (
GitRepository,
Project,
ToolInstance,
ToolType,
Workspace,
)
from src.services.shared.workspace_manager import WorkspaceManager
def _get_user_id_from_client(client: TestClient) -> uuid.UUID:
+2 -3
View File
@@ -2,7 +2,7 @@
dir: apps/api/tests/unit
## role
Unit test suite for the API backend, covering configuration, Docker services, git operations, file handling, health monitoring, notifications, permissions, and lifecycle management.
Unit test suite for the API backend, covering configuration, Docker operations, Git services, file handling, health monitoring, notifications, and SSH key management.
## parent
index: apps/api/tests/.pi-map.index.md
map: apps/api/tests/.pi-map.md
@@ -18,6 +18,7 @@ map: apps/api/tests/.pi-map.md
- test_file_service.py
- test_git_mount_resolution.py
- test_git_mounts.py
- test_git_operations.py
- test_git_repository_clone_preflight.py
- test_git_repository_working_clones.py
- test_git_service.py
@@ -32,9 +33,7 @@ map: apps/api/tests/.pi-map.md
- test_notifications_api_routes.py
- test_permission_fixer.py
- test_readiness_probe.py
- test_session_branch_selection.py
- test_ssh_keys.py
- test_tool_instances_legacy.py
## links
index: apps/api/tests/unit/.pi-map.index.md
map: apps/api/tests/unit/.pi-map.md
+5 -6
View File
@@ -4,7 +4,7 @@ dir: apps/api/tests/unit
index: apps/api/tests/unit/.pi-map.index.md
## role
Unit test suite for the API backend, covering configuration, Docker services, git operations, file handling, health monitoring, notifications, permissions, and lifecycle management.
Unit test suite for the API backend, covering configuration, Docker operations, Git services, file handling, health monitoring, notifications, and SSH key management.
## files
- __init__.py | Empty file with no functionality
- test_config.py | Unit tests for application configuration settings, database URL construction, and environment-specific behavior | exp: func:test_settings_default_database_url_uses_asyncpg() → None, call:Settings, func:test_build_database_url_uses_explicit_values() → None, call:build_database_url, func:test_settings_prefers_explicit_database_url_env(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_auth_settings_have_secure_defaults() → None, call:Settings, call:settings.resolved_authentik_authorize_url.endswith, call:settings.resolved_authentik_token_url.endswith, call:settings.resolved_authentik_jwks_url.endswith, func:test_cookie_policy_is_strict_in_production(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) → None, call:monkeypatch.setenv, call:Settings | dep: pytest, src.config, src.database
@@ -12,9 +12,10 @@ Unit test suite for the API backend, covering configuration, Docker services, gi
- test_docker_build.py | Unit tests for a Docker image build service that validates successful builds, failures, command structure, file writing, context handling, path traversal prevention, timeouts, and exceptions. | exp: class:TestBuildImage | dep: subprocess, tempfile, pathlib, unittest.mock, pytest, src.services.docker_build
- test_docker_service.py | Unit tests for Docker service utility functions including container ID/name retrieval and volume sorting by mount specificity. | exp: class:TestGetContainerId, class:TestGetContainerName, class:TestSortVolumesBySpecificity, method:test_parent_before_child(self) → None, call:sort_volumes_by_specificity, method:test_stable_sort_for_equal_depth(self) → None, call:sort_volumes_by_specificity, method:test_with_type_suffix(self) → None, call:sort_volumes_by_specificity, method:test_empty_list(self) → None, call:sort_volumes_by_specificity, method:test_single_volume(self) → None, call:sort_volumes_by_specificity, method:test_duplicate_target_warning(self, caplog) → None, call:caplog.at_level, call:sort_volumes_by_specificity | dep: unittest.mock, logging, src.services.docker, subprocess
- test_event_bus.py | Unit tests for InstanceEventBus verifying publish/subscribe behavior, exception isolation, unsubscribe functionality, and async callback support | exp: func:event_bus() → InstanceEventBus, call:InstanceEventBus, call:bus._reset_for_testing, func:sample_payload() → InstanceEventPayload, call:str, call:uuid.uuid4, func:test_publish_delivers_to_all_subscribers(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.publish, call:len, func:callback_1(payload: InstanceEventPayload) → None, call:received.append, func:callback_2(payload: InstanceEventPayload) → None, call:received.append, func:callback_3(payload: InstanceEventPayload) → None, call:received.append, func:test_subscriber_exception_isolation(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.publish, raise:RuntimeError, func:bad_callback(_payload: InstanceEventPayload) → None, raise:RuntimeError, func:good_callback(_payload: InstanceEventPayload) → None, call:received.append, func:test_unsubscribe_removes_callback(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:unsubscribe, call:event_bus.publish, func:callback(_payload: InstanceEventPayload) → None, call:received.append, func:test_publish_to_empty_subscriber_list(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:event_bus.publish, func:test_async_subscriber_supported(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:asyncio.sleep, call:received.append, call:event_bus.subscribe, call:event_bus.publish, func:async_callback(_payload: InstanceEventPayload) → None, call:asyncio.sleep, call:received.append, func:test_unsubscribe_all_clears_subscribers(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.unsubscribe_all, call:event_bus.publish, func:callback(_payload: InstanceEventPayload) → None, call:received.append | dep: asyncio, uuid, typing, pytest, src.services.event_bus
- test_file_service.py | Unit tests for FileService validating directory listing, file read/write, binary rejection, and path traversal prevention. | exp: class:TestFileService, method:test_list_directory_empty(self, temp_workspace: Workspace), call:FileService, call:service.list_directory, method:test_list_directory_with_files(self, temp_workspace: Workspace), call:os.makedirs, call:os.path.join, call:open, call:f.write, call:FileService, call:service.list_directory, call:len, method:test_read_file(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:FileService, call:service.read_file, method:test_read_binary_file_rejected(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:FileService, call:pytest.raises, call:service.read_file, method:test_write_file(self, temp_workspace: Workspace), call:FileService, call:service.write_file, call:os.path.exists, call:os.path.join, call:open, call:f.read, method:test_path_escapes_workspace(self, temp_workspace: Workspace), call:FileService, call:pytest.raises, call:service.list_directory, func:temp_workspace(), call:tempfile.TemporaryDirectory, call:Workspace | dep: os, tempfile, pytest, src.models.workspace, src.services.file_service
- test_file_service.py | Unit tests for FileService validating directory listing, file read/write, binary rejection, and path traversal prevention. | exp: class:TestFileService, method:test_list_directory_empty(self, temp_workspace: Workspace), call:FileService, call:service.list_directory, method:test_list_directory_with_files(self, temp_workspace: Workspace), call:os.makedirs, call:os.path.join, call:open, call:f.write, call:FileService, call:service.list_directory, call:len, method:test_read_file(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:FileService, call:service.read_file, method:test_read_binary_file_rejected(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:FileService, call:pytest.raises, call:service.read_file, method:test_write_file(self, temp_workspace: Workspace), call:FileService, call:service.write_file, call:os.path.exists, call:os.path.join, call:open, call:f.read, method:test_path_escapes_workspace(self, temp_workspace: Workspace), call:FileService, call:pytest.raises, call:service.list_directory, func:temp_workspace(), call:tempfile.TemporaryDirectory, call:Workspace | dep: os, tempfile, pytest, src.models, src.services.shared.file_service, src.models.Workspace, src.services.shared.file_service.FileService
- test_git_mount_resolution.py | Unit tests for git mount resolution functions including glob expansion, branch checkout, and mount validation in tool instances. | exp: class:TestExpandGlobSource, method:test_no_glob_single_file(self, tmp_path: Path) → None, call:test_file.write_text, call:_expand_glob_source, call:str, call:len, method:test_no_glob_missing_file(self, tmp_path: Path) → None, call:_expand_glob_source, call:str, call:len, method:test_glob_pattern(self, tmp_path: Path) → None, call:(tmp_path / "file1.txt").write_text, call:(tmp_path / "file2.txt").write_text, call:(tmp_path / "other.py").write_text, call:_expand_glob_source, call:str, call:len, call:all, call:f.endswith, method:test_glob_recursive(self, tmp_path: Path) → None, call:subdir.mkdir, call:(subdir / "nested.txt").write_text, call:_expand_glob_source, call:str, call:len, method:test_glob_limit_enforced(self, tmp_path: Path) → None, call:range, call:(tmp_path / f"file{i}.txt").write_text, call:_expand_glob_source, call:str, call:len, method:test_glob_escapes_repo(self, tmp_path: Path) → None, call:other_dir.mkdir, call:(other_dir / "outside.txt").write_text, call:_expand_glob_source, call:str, call:all, call:r.startswith, class:TestCheckoutBranch, method:test_checkout_existing_branch(self, tmp_path: Path) → None, call:os.system, call:(tmp_path / "file.txt").write_text, call:_checkout_branch, call:str, call:os.popen(f"cd {tmp_path} && git branch --show-current").read().strip, method:test_checkout_nonexistent_branch(self, tmp_path: Path) → None, call:os.system, call:(tmp_path / "file.txt").write_text, call:_checkout_branch, call:str, class:TestResolveSingleGitMount | dep: os, pathlib, pytest, src.api.tool_instances, pathlib.Path
- test_git_mounts.py | Unit tests for git mount resolution functions that handle multi-mapping support, normalization, glob expansion, and Docker volume bind mounting. | exp: class:TestNormalizeGitMount, method:test_legacy_to_mappings(self) → None, call:_normalize_git_mount, method:test_already_mappings(self) → None, call:_normalize_git_mount, method:test_missing_target_path_no_mappings(self) → None, call:_normalize_git_mount, class:TestResolveGitMountMappings, method:test_single_mapping(self) → None, call:tempfile.TemporaryDirectory, call:os.makedirs, call:os.path.join, call:_resolve_git_mount_mappings, call:len, method:test_multiple_mappings(self) → None, call:tempfile.TemporaryDirectory, call:os.makedirs, call:os.path.join, call:_resolve_git_mount_mappings, call:len, method:test_relative_target_path(self) → None, call:tempfile.TemporaryDirectory, call:os.makedirs, call:os.path.join, call:_resolve_git_mount_mappings, call:len, method:test_glob_expansion(self) → None, call:tempfile.TemporaryDirectory, call:os.makedirs, call:os.path.join, call:_resolve_git_mount_mappings, call:len, method:test_missing_target_path_skipped(self) → None, call:tempfile.TemporaryDirectory, call:_resolve_git_mount_mappings, call:len, method:test_no_working_directory_for_relative_target(self) → None, call:tempfile.TemporaryDirectory, call:os.makedirs, call:os.path.join, call:_resolve_git_mount_mappings, call:len, class:TestResolveSingleGitMount, class:TestExpandGlobSource, method:test_no_glob(self) → None, call:tempfile.TemporaryDirectory, call:os.path.join, call:open(path, "w").close, call:_expand_glob_source, method:test_no_glob_missing(self) → None, call:tempfile.TemporaryDirectory, call:os.path.join, call:_expand_glob_source, method:test_glob_pattern(self) → None, call:tempfile.TemporaryDirectory, call:open(os.path.join(tmp, "a.txt"), "w").close, call:os.path.join, call:open(os.path.join(tmp, "b.txt"), "w").close, call:_expand_glob_source, call:len | dep: os, tempfile, unittest.mock, pytest, src.api.tool_instances
- test_git_operations.py | Unit tests for GitOperations class covering git status, commit, history, and branch operations | exp: class:TestGitOperationsStatus, method:test_status_clean(self, temp_workspace: Workspace), call:GitOperations, call:asyncio.run, call:git.status, method:test_status_modified(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.status, method:test_status_untracked(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.status, class:TestGitOperationsCommit, method:test_commit_stages_and_commits(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.commit, call:git.status, call:git.history, method:test_commit_fails_without_changes(self, temp_workspace: Workspace), call:GitOperations, call:pytest.raises, call:asyncio.run, call:git.commit, class:TestGitOperationsHistory, method:test_history_returns_commits(self, temp_workspace: Workspace), call:GitOperations, call:asyncio.run, call:git.history, call:len, method:test_history_filters_by_path(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.commit, call:git.history, call:len, class:TestGitOperationsBranches, method:test_branches_lists_main(self, temp_workspace: Workspace), call:GitOperations, call:asyncio.run, call:git.branches, method:test_checkout_switches_branch(self, temp_workspace: Workspace), call:_run_git, call:GitOperations, call:asyncio.run, call:git.checkout, call:git.status, func:_run_git(*args: str, cwd: str) → None, call:subprocess.run, func:temp_workspace(), call:tempfile.TemporaryDirectory, call:_run_git, call:os.path.join, call:open, call:f.write, call:Workspace | dep: asyncio, os, subprocess, tempfile, pytest, src.models, src.services.git.git_operations, src.models.Workspace, src.services.git.git_operations.GitOperations
- test_git_repository_clone_preflight.py | Unit tests for git repository clone URL building and preflight accessibility checks | exp: func:test_build_provider_clone_url_uses_fixed_host() → None, call:_build_provider_clone_url, func:test_preflight_remote_repository_allows_accessible_repo() → None, call:Mock, call:patch, call:_preflight_remote_repository, call:run_mock.assert_called_once, func:test_preflight_remote_repository_rejects_missing_repo() → None, call:Mock, call:patch, call:pytest.raises, call:_preflight_remote_repository | dep: unittest.mock, pytest, fastapi, src.api.git_repositories, subprocess
- test_git_repository_working_clones.py | Tests git repository cloning and initialization utilities with fallback strategies for different git versions | exp: func:test_clone_working_repository_uses_normal_clone() → None, call:Mock, call:patch, call:_clone_working_repository, call:run_mock.assert_called_once, func:test_clone_working_repository_raises_on_failure() → None, call:Mock, call:patch, call:pytest.raises, call:_clone_working_repository, func:test_init_working_repository_prefers_init_b() → None, call:Mock, call:patch, call:_init_working_repository, func:test_init_working_repository_falls_back_to_symbolic_ref() → None, call:Mock, call:patch, call:_init_working_repository, func:test_create_branch_uses_orphan_checkout_when_head_is_unborn() → None, call:patch, call:create_branch, raise:RuntimeError, func:mock_run(repo_path: str, *args: str) → str, raise:RuntimeError | dep: unittest.mock, pytest, fastapi, src.api.git_repositories, src.utils.git_control, subprocess
- test_git_service.py | Unit tests for GitService class covering clone, fetch, pull, and branch_exists_remotely operations with mocked subprocess calls. | exp: class:TestGitServiceClone, class:TestGitServiceFetch, class:TestGitServicePull, class:TestGitServiceBranchExistsRemotely, method:test_branch_exists(self), call:MagicMock, call:patch, call:GitService.branch_exists_remotely, call:mock_run.assert_called_once_with, method:test_branch_not_exists(self), call:MagicMock, call:patch, call:GitService.branch_exists_remotely, method:test_ls_remote_fails(self), call:MagicMock, call:patch, call:GitService.branch_exists_remotely | dep: asyncio, unittest.mock, pytest, src.services.git_service
@@ -29,13 +30,11 @@ Unit test suite for the API backend, covering configuration, Docker services, gi
- test_notifications_api_routes.py | Tests that FastAPI notification routes are declared in correct order so bulk DELETE /notifications matches before parameterized DELETE /notifications/{id} | exp: func:test_delete_notifications_route_order() → None, call:FastAPI, call:app.include_router, call:TestClient, call:client.delete | dep: fastapi, fastapi.testclient, src.api.notifications
- test_permission_fixer.py | Unit tests for a Docker container permission fixer service that applies chown/chmod/file_mode to mounts and SSH directories. | exp: class:TestApplyMountPermissions, class:TestRunInContainer, class:TestApplySshPermissions, class:TestCheckRootUserAvailable | dep: unittest.mock, pytest, src.services.permission_fixer, subprocess
- test_readiness_probe.py | Unit tests for a Docker container readiness probe service that executes commands via docker exec with retry logic and timeout handling. | exp: class:TestExecuteProbe, class:TestIntegrationScenarios | dep: unittest.mock, src.services.readiness_probe, subprocess
- test_session_branch_selection.py | Tests session creation with branch selection and new branch creation via git operations and API request validation. | exp: class:TestCreateInstanceRequest, method:test_default_values(self), call:CreateInstanceRequest, method:test_clone_mode_with_branch(self), call:CreateInstanceRequest, method:test_new_branch_field(self), call:CreateInstanceRequest, class:TestBranchCreationInClone, method:test_create_local_branch_success(self), call:tempfile.TemporaryDirectory, call:subprocess.run, call:os.path.join, call:open, call:f.write, call:branches_result.stdout.strip, method:test_create_local_branch_invalid_name(self), call:tempfile.TemporaryDirectory, call:subprocess.run, call:os.path.join, call:open, call:f.write, class:TestCreateInstanceAPI, method:test_create_instance_request_validation(self), call:CreateInstanceRequest, method:test_create_instance_with_new_branch_sets_instance_branch(self) | dep: os, subprocess, tempfile, src.api.tool_instances, src.api.tool_instances.CreateInstanceRequest
- test_ssh_keys.py | Unit tests for SSH key preparation functionality that decrypts encrypted private keys and writes SSH key files with proper permissions and optional ownership. | exp: class:TestPrepareSshKeyFiles | dep: os, pathlib, unittest.mock, pytest, src.services.ssh_keys
- test_tool_instances_legacy.py | Unit tests verifying legacy tool instance fallback paths (dockerfile, compose, legacy definition types) continue using original startup flow after manifest-based flow introduction | exp: class:TestCreateInstanceDockerfileLegacy, class:TestCreateInstanceComposeLegacy, class:TestCreateInstanceManifestNotCalledForLegacy, class:TestStartInstanceLegacyFallback, class:TestStartInstanceSshPermissions, class:TestStartInstanceManifestBranch, func:fake_user_id() → uuid.UUID, call:uuid.uuid4, func:fake_project_id() → uuid.UUID, call:uuid.uuid4, func:fake_repo_id() → uuid.UUID, call:uuid.uuid4, func:fake_tool_type_id() → uuid.UUID, call:uuid.uuid4, func:fake_instance_id() → uuid.UUID, call:uuid.uuid4, func:mock_session(fake_user_id, fake_project_id, fake_repo_id, fake_tool_type_id), call:AsyncMock, call:User, call:MagicMock, call:GitRepository, call:getattr, call:datetime.now, func:_get(model, pk), func:_add(instance), call:getattr, call:datetime.now | dep: os, uuid, datetime, unittest.mock, pytest, src.api.tool_instances, src.models.git_repository, src.models.tool_instance, src.models.tool_type, src.models.ssh_key, src.models.user, fastapi
## arch
Pytest-based unit testing with heavy mocking of external dependencies (Docker, subprocess, git, filesystem), organized by service/component under test, using parameterized tests and fixture-based setup for isolated component verification.
pytest-based unit testing with heavy mocking of external dependencies (Docker, Git subprocesses, databases), organized by service/component under test, using parameterized tests and fixture-based setup for isolated component verification.
## tags
test, url, call:, git, call:notification, instance, mount, call:len
test, call:, url, git, call:notification, mount, call:len, call:db
## symbols
- TestMergeFunctions
- TestResolveProfile
+2 -2
View File
@@ -5,8 +5,8 @@ import tempfile
import pytest
from src.models.workspace import Workspace
from src.services.file_service import FileService
from src.models import Workspace
from src.services.shared.file_service import FileService
@pytest.fixture
+133
View File
@@ -0,0 +1,133 @@
"""Unit tests for GitOperations."""
import asyncio
import os
import subprocess
import tempfile
import pytest
from src.models import Workspace
from src.services.git.git_operations import GitOperations
def _run_git(*args: str, cwd: str) -> None:
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
@pytest.fixture
def temp_workspace():
"""Create a temporary git workspace."""
with tempfile.TemporaryDirectory() as tmpdir:
_run_git("init", cwd=tmpdir)
_run_git("config", "user.email", "test@test.com", cwd=tmpdir)
_run_git("config", "user.name", "Test User", cwd=tmpdir)
readme = os.path.join(tmpdir, "README.md")
with open(readme, "w") as f:
f.write("# Test\n")
_run_git("add", "README.md", cwd=tmpdir)
_run_git("commit", "-m", "Initial commit", cwd=tmpdir)
ws = Workspace(
id="00000000-0000-0000-0000-000000000001",
name="test-ws",
repo_id="00000000-0000-0000-0000-000000000002",
user_id="00000000-0000-0000-0000-000000000003",
branch="main",
path=tmpdir,
)
yield ws
class TestGitOperationsStatus:
"""Tests for GitOperations.status."""
def test_status_clean(self, temp_workspace: Workspace):
git = GitOperations(temp_workspace)
status = asyncio.run(git.status())
assert status.branch == "main"
assert status.modified == []
assert status.added == []
assert status.deleted == []
assert status.untracked == []
def test_status_modified(self, temp_workspace: Workspace):
with open(os.path.join(temp_workspace.path, "README.md"), "a") as f:
f.write("modified\n")
git = GitOperations(temp_workspace)
status = asyncio.run(git.status())
assert "README.md" in status.modified
def test_status_untracked(self, temp_workspace: Workspace):
with open(os.path.join(temp_workspace.path, "new.py"), "w") as f:
f.write("print('hello')\n")
git = GitOperations(temp_workspace)
status = asyncio.run(git.status())
assert "new.py" in status.untracked
class TestGitOperationsCommit:
"""Tests for GitOperations.commit."""
def test_commit_stages_and_commits(self, temp_workspace: Workspace):
with open(os.path.join(temp_workspace.path, "new.py"), "w") as f:
f.write("print('hello')\n")
git = GitOperations(temp_workspace)
asyncio.run(git.commit("Add new file"))
status = asyncio.run(git.status())
assert "new.py" not in status.untracked
assert "new.py" not in status.added
history = asyncio.run(git.history())
assert history[0].message == "Add new file"
def test_commit_fails_without_changes(self, temp_workspace: Workspace):
git = GitOperations(temp_workspace)
with pytest.raises(RuntimeError):
asyncio.run(git.commit("Nothing to commit"))
class TestGitOperationsHistory:
"""Tests for GitOperations.history."""
def test_history_returns_commits(self, temp_workspace: Workspace):
git = GitOperations(temp_workspace)
history = asyncio.run(git.history())
assert len(history) >= 1
assert history[0].message == "Initial commit"
def test_history_filters_by_path(self, temp_workspace: Workspace):
with open(os.path.join(temp_workspace.path, "new.py"), "w") as f:
f.write("print('hello')\n")
git = GitOperations(temp_workspace)
asyncio.run(git.commit("Add new file"))
history = asyncio.run(git.history(path="new.py"))
assert len(history) == 1
assert history[0].message == "Add new file"
class TestGitOperationsBranches:
"""Tests for GitOperations.branches."""
def test_branches_lists_main(self, temp_workspace: Workspace):
git = GitOperations(temp_workspace)
branches, current = asyncio.run(git.branches())
assert "main" in branches
assert current == "main"
def test_checkout_switches_branch(self, temp_workspace: Workspace):
_run_git("checkout", "-b", "feature", cwd=temp_workspace.path)
_run_git("checkout", "main", cwd=temp_workspace.path)
git = GitOperations(temp_workspace)
asyncio.run(git.checkout("feature"))
status = asyncio.run(git.status())
assert status.branch == "feature"
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web
## role
Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project.
Frontend web application for a React-based developer workspace with code editing, terminal, and routing capabilities.
## 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 providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project.
Frontend web application for a React-based developer workspace with code editing, terminal, and routing capabilities.
## 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 providing a React-based UI with code editing, terminal,
- 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 and TypeScript, containerized via multi-stage Docker with nginx serving, featuring client-side routing, optimized static asset delivery, and development tooling (ESLint, Vitest).
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.
## 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 single-page application with authentication, routing, and domain type definitions.
Frontend web application entry point and core infrastructure for a React-based project management or development platform.
## parent
index: apps/web/.pi-map.index.md
map: apps/web/.pi-map.md
+4 -4
View File
@@ -4,15 +4,15 @@ dir: apps/web/src
index: apps/web/src/.pi-map.index.md
## role
Frontend web application entry point and core infrastructure for a React single-page application with authentication, routing, and domain type definitions.
Frontend web application entry point and core infrastructure for a React-based project management or development platform.
## files
- main.tsx | Entry point that bootstraps a React SPA with routing, authentication, and session management context 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/repo-workspace.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, ./styles/*
- 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 using context providers for cross-cutting concerns (auth/session), declarative routing with nested layouts and route guards, and centralized TypeScript type definitions for domain models.
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.
## tags
pages, styles, css, router, react, session, dom, workspace
pages, styles, css, router, react, session, dom, project
## symbols
- AppRouter
- SessionUser
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/components
## role
Provides foundational, reusable UI components and utilities for the web application, including layout shell, data states, icon system, code display, routing guards, and toast notification rules.
Provides reusable, foundational React UI components for layout, authentication, data display, icons, code editing, and notifications across the web application.
## 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 foundational, reusable UI components and utilities for the web application, including layout shell, data states, icon system, code display, routing guards, and toast notification rules.
Provides reusable, foundational React UI components for layout, authentication, data display, icons, code editing, and notifications across the web application.
## 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 foundational, reusable UI components and utilities for the web applicat
- 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
Follows a component-based React architecture with separation of concerns between presentational components (icon, data-states, code-editor), layout/app-shell orchestration, auth-guarded routing (protected-route), and domain-specific utility modules (toast-rules) with colocated unit tests.
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).
## tags
toast, state, react, icon, code, event, editor, protected
## symbols
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features
## role
Contains specialized UI components for major feature areas of the web application, organizing components by business domain rather than by atomic design level.
Contains reusable React components that implement specific user-facing functionality and business logic features across 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 specialized UI components for major feature areas of the web application, organizing components by business domain rather than by atomic design level.
Contains reusable React components that implement specific user-facing functionality and business logic features across the web application.
## files
## arch
Feature-based colocation pattern where components are grouped by product functionality (e.g., checkout, dashboard, settings) rather than by component type, typically combining multiple atomic components with domain-specific logic and data fetching.
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.
## tags
-
## symbols
@@ -1,102 +0,0 @@
import { useState } from "react";
import { commitChanges } from "../../../api/git-repositories";
interface CommitPanelProps {
projectId: string;
repoId: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
onCommit: () => void;
}
export const CommitPanel = ({
projectId,
repoId,
modified,
added,
deleted,
untracked,
onCommit,
}: CommitPanelProps) => {
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const allFiles = [...modified, ...added, ...deleted, ...untracked];
const hasChanges = allFiles.length > 0;
const handleCommit = async () => {
if (!message.trim()) {
setError("Please enter a commit message");
return;
}
setLoading(true);
setError(null);
try {
await commitChanges(projectId, repoId, message);
setMessage("");
onCommit();
} catch {
setError("Commit failed. Please try again.");
} finally {
setLoading(false);
}
};
if (!hasChanges) return null;
return (
<div className="commit-panel">
<h4>Changes</h4>
<div className="file-list">
{modified.map((file) => (
<div key={file} className="file-item modified">
<span className="file-status">M</span>
<span className="file-name">{file}</span>
</div>
))}
{added.map((file) => (
<div key={file} className="file-item added">
<span className="file-status">A</span>
<span className="file-name">{file}</span>
</div>
))}
{deleted.map((file) => (
<div key={file} className="file-item deleted">
<span className="file-status">D</span>
<span className="file-name">{file}</span>
</div>
))}
{untracked.map((file) => (
<div key={file} className="file-item untracked">
<span className="file-status">?</span>
<span className="file-name">{file}</span>
</div>
))}
</div>
<div className="commit-form">
<textarea
placeholder="Commit message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
className="commit-message-input"
/>
{error && <div className="commit-error">{error}</div>}
<button
onClick={handleCommit}
disabled={loading || !message.trim()}
className="commit-button"
type="button"
>
{loading ? "Committing..." : "Commit"}
</button>
</div>
</div>
);
};
@@ -1,241 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiClient } from "../../../api/client";
import { useAuth } from "../../../state/auth";
import { CodeEditor } from "../../code-editor";
import { CommitDialog } from "./commit-dialog";
import { Icon } from "../../icon";
import { SyntaxHighlighter } from "../../syntax-highlighter";
import { detectLanguage } from "../../../utils/language";
interface FileEditorProps {
projectId: string;
repoId: string;
}
export const FileEditor: React.FC<FileEditorProps> = ({
projectId,
repoId,
}) => {
const [searchParams] = useSearchParams();
const { user } = useAuth();
const [mode, setMode] = useState<"view" | "edit">("view");
const [content, setContent] = useState(">");
const [originalContent, setOriginalContent] = useState(">");
const [language, setLanguage] = useState("plaintext");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCommitDialog, setShowCommitDialog] = useState(false);
const [isBinary, setIsBinary] = useState(false);
const [saving, setSaving] = useState(false);
const branch = searchParams.get("branch") || "main";
const filePath = searchParams.get("file");
const loadFile = useCallback(async () => {
if (!filePath) {
setContent("");
setOriginalContent("");
return;
}
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
params: {
branch,
path: filePath,
},
}
);
const data = response.data;
if (data.is_binary) {
setIsBinary(true);
setContent("Binary file - cannot display");
setOriginalContent("");
} else {
setIsBinary(false);
setContent(data.content);
setOriginalContent(data.content);
setLanguage(detectLanguage(filePath));
}
} catch {
setError("Failed to load file");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, filePath]);
useEffect(() => {
void loadFile();
}, [loadFile]);
const handleEdit = () => {
if (isBinary) return;
setMode("edit");
};
const handleCancel = () => {
setContent(originalContent);
setMode("view");
setShowCommitDialog(false);
};
const handleSave = () => {
if (content === originalContent) {
setMode("view");
return;
}
setShowCommitDialog(true);
};
const handleCommit = async (message: string) => {
if (!filePath || !user) return;
setSaving(true);
try {
await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
path: filePath,
branch,
content,
commit_message: message,
author_name: user.name || "User",
author_email: user.email || "user@example.com",
}
);
setOriginalContent(content);
setMode("view");
setShowCommitDialog(false);
} catch {
setError("Failed to save changes");
} finally {
setSaving(false);
}
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
e.preventDefault();
if (mode === "view" && !isBinary) {
handleEdit();
} else if (mode === "edit") {
handleCancel();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (mode === "edit") {
handleSave();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [mode, isBinary, content, originalContent]);
if (!filePath) {
return (
<div className="file-viewer-empty">
<p className="muted">Select a file to view its contents</p>
</div>
);
}
if (loading) return <p className="muted">Loading file...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className="file-editor">
<div className="file-editor-toolbar">
<div className="file-breadcrumbs">
{filePath.split("/").map((part, i, arr) => (
<span key={i}>
{part}
{i < arr.length - 1 && (
<span className="breadcrumb-sep">/</span>
)}
</span>
))}
</div>
<div className="file-actions">
{mode === "view" && !isBinary && (
<button
className="btn-primary"
onClick={handleEdit}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
)}
{mode === "edit" && (
<>
<button
className="btn-primary"
onClick={handleSave}
disabled={content === originalContent || saving}
type="button"
>
{saving ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
<button
className="btn-secondary"
onClick={handleCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</>
)}
</div>
</div>
<div className="file-editor-content">
{mode === "view" && (
<SyntaxHighlighter
code={content}
language={language}
showLineNumbers={!isBinary}
/>
)}
{mode === "edit" && (
<CodeEditor
value={content}
onChange={setContent}
language={language}
/>
)}
</div>
<CommitDialog
isOpen={showCommitDialog}
filePath={filePath}
originalContent={originalContent}
newContent={content}
onCommit={handleCommit}
onCancel={() => setShowCommitDialog(false)}
/>
</div>
);
};
@@ -1,276 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import {
checkoutBranch,
createBranch,
fetchRepository,
getRepositoryStatus,
pullRepository,
pushRepository,
type GitStatus,
} from "../../../api/git-repositories";
import { Icon } from "../../icon";
import { MergeDialog } from "./merge-dialog";
interface GitToolbarProps {
projectId: string;
repoId: string;
currentBranch: string;
branches: string[];
hasRemote: boolean;
isMirror: boolean;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
export const GitToolbar = ({
projectId,
repoId,
currentBranch,
branches,
hasRemote,
isMirror,
onBranchChange,
onRefresh,
}: GitToolbarProps) => {
const [status, setStatus] = useState<GitStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showNewBranch, setShowNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [newBranchBase, setNewBranchBase] = useState("");
const [showMergeDialog, setShowMergeDialog] = useState(false);
const loadStatus = useCallback(async () => {
try {
const data = await getRepositoryStatus(projectId, repoId);
setStatus(data);
setError(null);
} catch {
setError("Failed to load status");
}
}, [projectId, repoId]);
useEffect(() => {
void loadStatus();
// Poll status every 5 seconds
const interval = setInterval(() => void loadStatus(), 5000);
return () => clearInterval(interval);
}, [loadStatus]);
const handleFetch = async () => {
if (!hasRemote) return;
setLoading(true);
try {
await fetchRepository(projectId, repoId);
await loadStatus();
} catch {
setError("Fetch failed");
} finally {
setLoading(false);
}
};
const handlePull = async () => {
if (!hasRemote) return;
setLoading(true);
try {
await pullRepository(projectId, repoId, currentBranch || undefined);
await loadStatus();
onRefresh();
} catch {
setError("Pull failed");
} finally {
setLoading(false);
}
};
const handlePush = async () => {
setLoading(true);
try {
await pushRepository(projectId, repoId, currentBranch);
await loadStatus();
} catch {
setError("Push failed");
} finally {
setLoading(false);
}
};
const handleCheckout = async (branch: string) => {
setLoading(true);
try {
await checkoutBranch(projectId, repoId, branch);
onBranchChange(branch);
onRefresh();
} catch {
setError("Checkout failed");
} finally {
setLoading(false);
}
};
const handleCreateBranch = async () => {
if (!newBranchName.trim()) return;
setLoading(true);
try {
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
setShowNewBranch(false);
setNewBranchName("");
setNewBranchBase("");
onRefresh();
} catch {
setError("Failed to create branch");
} finally {
setLoading(false);
}
};
const hasChanges = status && (
status.modified.length > 0 ||
status.added.length > 0 ||
status.deleted.length > 0 ||
status.untracked.length > 0
);
const canSync = hasRemote;
return (
<div className="git-toolbar">
{error && <div className="toolbar-error">{error}</div>}
{isMirror && (
<div className="warning-message">
<Icon name="warning" size="sm" /> This repository is a bare mirror.
Editing, committing, pulling, and merging are not available.
Delete and recreate it to enable full workspace features.
</div>
)}
<div className="toolbar-row">
<div className="toolbar-group">
<select
value={currentBranch}
onChange={(e) => handleCheckout(e.target.value)}
disabled={loading}
className="branch-select"
>
{branches.map((b) => (
<option key={b} value={b}>
{b === currentBranch ? (
<>
<Icon name="branch" size="sm" /> {b}
</>
) : (
b
)}
</option>
))}
</select>
<button
className="toolbar-button"
onClick={() => setShowNewBranch(!showNewBranch)}
disabled={loading}
type="button"
>
<Icon name="add" size="sm" /> New
</button>
</div>
<div className="toolbar-group">
<button
className="toolbar-button"
onClick={handleFetch}
disabled={loading || !canSync}
type="button"
>
<Icon name="fetch" size="sm" /> Fetch
</button>
<button
className="toolbar-button"
onClick={handlePull}
disabled={loading || !canSync}
type="button"
>
<Icon name="pull" size="sm" /> Pull
{status?.behind ? <span className="badge">{status.behind}</span> : null}
</button>
<button
className="toolbar-button"
onClick={handlePush}
disabled={loading || !canSync || !status?.ahead}
type="button"
>
<Icon name="push" size="sm" /> Push
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
</button>
<button
className="toolbar-button"
onClick={() => setShowMergeDialog(true)}
disabled={loading}
type="button"
>
<Icon name="merge" size="sm" /> Merge
</button>
</div>
</div>
{showNewBranch && (
<div className="toolbar-row new-branch-form">
<input
type="text"
placeholder="Branch name"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
className="toolbar-input"
/>
<select
value={newBranchBase}
onChange={(e) => setNewBranchBase(e.target.value)}
className="toolbar-input"
>
<option value="">Base: HEAD</option>
{branches.map((b) => (
<option key={b} value={b}>{ b}</option>
))}
</select>
<button
className="toolbar-button primary"
onClick={handleCreateBranch}
disabled={loading || !newBranchName.trim()}
type="button"
>
<Icon name="add" size="sm" /> Create
</button>
<button
className="toolbar-button"
onClick={() => setShowNewBranch(false)}
type="button"
>
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
)}
{hasChanges && status && (
<div className="toolbar-row status-summary">
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
</div>
)}
<MergeDialog
projectId={projectId}
repoId={repoId}
branches={branches}
currentBranch={currentBranch}
isOpen={showMergeDialog}
onClose={() => setShowMergeDialog(false)}
onMerge={() => {
void loadStatus();
onRefresh();
}}
/>
</div>
);
};
@@ -2,13 +2,14 @@
dir: apps/web/src/components/features/project
## role
Provides UI components for managing projects, including cards, dialogs, and repository settings with full CRUD operations and Git integration.
Provides React components for project management UI including project cards, creation/editing dialogs, and repository settings with CRUD operations.
## parent
index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md
## children
-
## files
- ProjectCard.test.tsx
- ProjectCard.tsx
- ProjectDialog.tsx
- repositories-settings-tab.test.tsx
@@ -21,6 +22,6 @@ map: apps/web/src/components/features/project/.pi-map.md
- change project behavior
read: ProjectCard.tsx, ProjectDialog.tsx, repositories-settings-tab.tsx
- update project tests
read: repositories-settings-tab.test.tsx
read: ProjectCard.test.tsx, repositories-settings-tab.test.tsx
## dirty
-
@@ -4,17 +4,18 @@ dir: apps/web/src/components/features/project
index: apps/web/src/components/features/project/.pi-map.index.md
## role
Provides UI components for managing projects, including cards, dialogs, and repository settings with full CRUD operations and Git integration.
Provides React components for project management UI including project cards, creation/editing dialogs, and repository settings with CRUD operations.
## files
- ProjectCard.test.tsx | Unit tests for the ProjectCard component verifying rendering, workspace creation, and delete confirmation behavior. | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./ProjectCard, ../../../types, @testing-library/jest-dom, ProjectCard, types
- ProjectCard.tsx | Renders an expandable project card displaying project info, repositories, and associated workspaces with CRUD actions. | exp: ProjectCard | dep: ../../icon, ../workspace/workspace-create-form, ../../../types, Icon, WorkspaceCreateForm, ProjectWithRepos, WorkspaceSummary
- ProjectDialog.tsx | Renders a modal dialog for creating or editing a project with name/description inputs and cancel/submit actions. | exp: ProjectDialog | dep: ../../icon, React, icon
- repositories-settings-tab.test.tsx | Tests the RepositoriesSettingsTab component's repository creation flows including full URL cloning, owner/repo mode with SSH key selection, and SSH URL validation. | dep: @testing-library/react, vitest, ./repositories-settings-tab, ../../../api/git-repositories, ../../../api/ssh-keys, react-router-dom
- repositories-settings-tab.tsx | A React component that displays and manages Git repositories for a project, allowing users to list, create, and delete repositories. | exp: RepositoriesSettingsTab | dep: react, react-router-dom, ../../../api/git-repositories, ./repository-create-dialog, ../../icon
- repository-create-dialog.tsx | React dialog component for creating or cloning Git repositories with URL validation and SSH key selection. | exp: RepositoryCreateDialog | dep: react, ../../../api/git-repositories, ../../../api/ssh-keys, ../../icon
## arch
Feature-based component architecture with compound UI patterns (card/dialog/tab), form handling for CRUD, and SSH-aware Git repository management with validation testing.
Feature-based component architecture with compound component patterns (card + dialog), test co-location, and separation of presentational components from business logic through dialog-based workflows.
## tags
repositories, project, dialog, react, create, settings, icon, repository
project, repositories, dialog, react, create, settings, icon, repository
## symbols
- ProjectCard
- ProjectDialog
@@ -24,6 +25,6 @@ repositories, project, dialog, react, create, settings, icon, repository
- change project behavior
read: ProjectCard.tsx, ProjectDialog.tsx, repositories-settings-tab.tsx
- update project tests
read: repositories-settings-tab.test.tsx
read: ProjectCard.test.tsx, repositories-settings-tab.test.tsx
## dirty
-
@@ -0,0 +1,117 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
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,
},
],
},
],
};
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>
);
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>
);
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>
);
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
});
@@ -1,132 +0,0 @@
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>
);
};
@@ -1,214 +0,0 @@
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>
</>
);
};
-148
View File
@@ -1,148 +0,0 @@
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,
};
};
+1 -1
View File
@@ -10,7 +10,7 @@ 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";
+3 -3
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/pages
## role
Implements the complete set of top-level routed page components for the web application, covering all major user-facing features including project management, git operations, workspace management, terminal access, user settings, and system administration.
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.
## parent
index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md
@@ -19,12 +19,12 @@ map: apps/web/src/.pi-map.md
- ProjectSettingsPage.tsx
- ProjectsPage.test.tsx
- ProjectsPage.tsx
- RepoWorkspacePage.tsx
- SessionsPage.tsx
- SettingsPage.tsx
- SshKeysPage.tsx
- TerminalPage.tsx
- ToolWorkshopPage.tsx
- WorkspaceDetailPage.test.tsx
- WorkspaceDetailPage.tsx
- WorkspacesPage.tsx
## links
@@ -34,6 +34,6 @@ map: apps/web/src/pages/.pi-map.md
- change pages behavior
read: ConfigProfilesPage.tsx, DashboardPage.tsx, GitHistoryPage.tsx
- update pages tests
read: DashboardPage.test.tsx, ProjectsPage.test.tsx
read: DashboardPage.test.tsx, ProjectsPage.test.tsx, WorkspaceDetailPage.test.tsx
## dirty
-
+6 -6
View File
@@ -4,10 +4,10 @@ dir: apps/web/src/pages
index: apps/web/src/pages/.pi-map.index.md
## role
Implements the complete set of top-level routed page components for the web application, covering all major user-facing features including project management, git operations, workspace management, terminal access, user settings, and system administration.
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.
## 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 | Tests the DashboardPage (HomePage) component's rendering, loading states, and error handling with retry functionality. | dep: @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.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.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
@@ -16,18 +16,18 @@ Implements the complete set of top-level routed page components for the web appl
- ProjectSettingsPage.tsx | Renders a project settings page with tabbed navigation for general settings, repositories, and members, including project data fetching, editing, and deletion capabilities. | exp: ProjectSettingsPage | dep: react, react-router-dom, ../components/features/settings/settings-tab-layout, ../components/features/project/repositories-settings-tab, ../api/client, ../types
- ProjectsPage.test.tsx | Unit tests for the ProjectsPage component covering loading, empty, error, create, edit, and delete states with API mocking. | dep: @testing-library/react, react-router-dom, vitest, ./ProjectsPage, ../api/projects, ProjectsPage
- ProjectsPage.tsx | Renders a responsive projects management page with separate mobile and desktop layouts, supporting project CRUD operations, repository creation, and workspace management. | exp: ProjectsPage | dep: react, ../components/data-states, ../components/icon, ../hooks/use-mobile-viewport, ../components/features/project/ProjectCard, ../components/features/project/ProjectDialog, ../components/features/project/repository-create-dialog, ../components/features/mobile/mobile-list-view, ../components/features/mobile/mobile-fab, ../hooks/use-projects, ../types
- RepoWorkspacePage.tsx | Renders a repository workspace page with loading/error/empty states and a responsive layout for managing repositories, branches, and git operations. | exp: RepoWorkspace | dep: react, react-router-dom, ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-repo-workspace, ../components/features/workspace/workspace-header, ../components/features/workspace/WorkspaceLayout
- SessionsPage.tsx | Renders a sessions management page that displays, polls health for, and handles CRUD operations on development environment sessions with dirty delete confirmation. | exp: SessionsPage | dep: react, ../api/sessions, ../api/settings, ../components/data-states, ../components/features/session/session-list, ../components/features/session/session-card, ../hooks/use-instance-actions, ../state/sessions
- SettingsPage.tsx | A React settings page component that loads, displays, and manages user configuration with tabbed navigation and nested outlet for child routes. | exp: SettingsPage | dep: react, react-router-dom, ../api/settings, ../components/data-states, ../hooks/use-async-data, ../components/features/settings/GeneralSettingsTab
- 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
- 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 where each file corresponds to a route endpoint, using responsive conditional rendering patterns (mobile/desktop split layouts), tabbed navigation with nested outlets, polling-based real-time data updates, and container-presentational component separation with embedded data fetching, state management, and CRUD operations directly in page components.
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.
## tags
page, components, workspace, react, features, mobile, hooks, settings
page, components, react, workspace, features, mobile, hooks, settings
## symbols
- WorkspaceDetailPage
- WorkspacesPage
@@ -41,6 +41,6 @@ page, components, workspace, react, features, mobile, hooks, settings
- change pages behavior
read: ConfigProfilesPage.tsx, DashboardPage.tsx, GitHistoryPage.tsx
- update pages tests
read: DashboardPage.test.tsx, ProjectsPage.test.tsx
read: DashboardPage.test.tsx, ProjectsPage.test.tsx, WorkspaceDetailPage.test.tsx
## dirty
-
-89
View File
@@ -1,89 +0,0 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useRepoWorkspace } from "../hooks/use-repo-workspace";
import { WorkspaceHeader } from "../components/features/workspace/workspace-header";
import { WorkspaceLayout } from "../components/features/workspace/WorkspaceLayout";
type MobileTab = "files" | "editor" | "git" | "terminal";
export const RepoWorkspace = () => {
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");
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 && (
<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>
);
};
@@ -0,0 +1,126 @@
import "@testing-library/jest-dom/vitest";
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";
afterEach(() => {
cleanup();
});
import { WorkspaceDetailPage } from "./WorkspaceDetailPage";
const mockWorkspaces = [
{
id: "ws-1",
name: "dev",
repo_name: "my-repo",
project_name: "My Project",
branch: "main",
path: "/data/ws/dev",
status: "ready",
instance_count: 0,
},
];
vi.mock("../hooks/use-workspaces", () => ({
useWorkspaces: () => ({
workspaces: mockWorkspaces,
loading: false,
error: null,
refresh: vi.fn(),
}),
}));
vi.mock("../hooks/use-workspace-files", () => ({
useWorkspaceFiles: () => ({
entries: [],
content: null,
currentPath: "",
loading: false,
error: null,
loadFile: vi.fn(),
saveFile: vi.fn(),
navigateTo: vi.fn(),
}),
}));
vi.mock("../hooks/use-workspace-git", () => ({
useWorkspaceGit: () => ({
status: {
branch: "main",
modified: [],
added: [],
deleted: [],
untracked: [],
ahead: 0,
behind: 0,
},
history: [],
branches: ["main"],
currentBranch: "main",
loading: false,
error: null,
commit: vi.fn(),
push: vi.fn(),
pull: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
}),
}));
vi.mock("../hooks/use-workspace-instances", () => ({
useWorkspaceInstances: () => ({
instances: [],
loading: false,
error: null,
refresh: vi.fn(),
}),
}));
vi.mock("../hooks/use-mobile-viewport", () => ({
useMobileViewport: () => false,
}));
describe("WorkspaceDetailPage", () => {
it("renders workspace header and tabs", async () => {
render(
<MemoryRouter initialEntries={["/workspaces/ws-1"]}>
<Routes>
<Route
path="/workspaces/:workspaceId"
element={<WorkspaceDetailPage />}
/>
</Routes>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText("dev")).toBeInTheDocument();
});
expect(screen.getByText(/my-repo/i)).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /files/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /git/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /tools/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /settings/i })).toBeInTheDocument();
});
it("switches tabs", async () => {
render(
<MemoryRouter initialEntries={["/workspaces/ws-1"]}>
<Routes>
<Route
path="/workspaces/:workspaceId"
element={<WorkspaceDetailPage />}
/>
</Routes>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /settings/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("tab", { name: /settings/i }));
expect(screen.getByText(/workspace info/i)).toBeInTheDocument();
});
});
@@ -1,709 +0,0 @@
/* 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;
}
+1 -1
View File
@@ -2,7 +2,7 @@
dir: openspec
## role
Defines the OpenSpec methodology and project configuration for managing living documentation and development discipline rules in a Docker-based coding agent platform.
Defines the OpenSpec methodology and project configuration for managing software requirements, specifications, and task tracking as living documentation within a repository.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+2 -2
View File
@@ -4,12 +4,12 @@ dir: openspec
index: openspec/.pi-map.index.md
## role
Defines the OpenSpec methodology and project configuration for managing living documentation and development discipline rules in a Docker-based coding agent platform.
Defines the OpenSpec methodology and project configuration for managing software requirements, specifications, and task tracking as living documentation within a repository.
## files
- README.md | Documents the OpenSpec methodology for managing software requirements, specifications, and task tracking as living documentation within a project repository. | dep: OpenSpec CLI (@fission-ai/openspec), Docker, SQLAlchemy, Alembic, Authentik, React, TypeScript, Tailwind, Traefik, Jinja2, xterm.js, pytest, mypy, ruff, npm
- config.yaml | Configuration file defining project metadata, technology stack, and software development discipline rules for a Docker-based coding agent management platform | dep: FastAPI, React, Vite, PostgreSQL, SQLAlchemy, Redis, Alembic, pytest, Docker Compose, Traefik, Authentik
## arch
Documentation-as-code pattern with YAML-based configuration management, embedding requirements/specifications directly in the repository alongside structured project metadata and technology stack definitions.
Uses a documentation-as-code pattern with YAML-based configuration for project metadata and discipline rules, supporting a Docker-based coding agent management platform.
## tags
software, project, docker, sqlalchemy, alembic, authentik, react, traefik
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: openspec/changes
## role
Manages change tracking, versioning, and audit logging for OpenSpec schema or configuration modifications.
Tracks and manages specification changes, versions, and changelog entries for the OpenAPI specification.
## parent
index: openspec/.pi-map.index.md
map: openspec/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: openspec/changes
index: openspec/changes/.pi-map.index.md
## role
Manages change tracking, versioning, and audit logging for OpenSpec schema or configuration modifications.
Tracks and manages specification changes, versions, and changelog entries for the OpenAPI specification.
## files
## arch
Event-sourced or changelog-based architecture with immutable change records, likely supporting rollback, diff computation, and history querying patterns.
Simple data model package using immutable value objects with builder pattern for constructing change records and version metadata.
## tags
-
## symbols
@@ -0,0 +1,4 @@
name: workspace-first-ui
status: completed
started_at: 2026-05-28
completed_at: 2026-06-12
@@ -1,21 +1,22 @@
# workspace-first-ui (index)
dir: workspace-first-ui
# openspec/changes/workspace-first-ui (index)
dir: openspec/changes/workspace-first-ui
## role
Design and planning documentation for a workspace-first UI refresh that restructures the application around persistent writable workspaces rather than direct repository browsing.
Product specification and design documentation package for a workspace-first UI refresh project that reorients the application around persistent writable workspaces rather than direct repository browsing.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
index: openspec/changes/.pi-map.index.md
map: openspec/changes/.pi-map.md
## children
-
## files
- .openspec.yaml
- design.md
- proposal.md
- spec.md
- tasks.md
## links
index: workspace-first-ui/.pi-map.index.md
map: workspace-first-ui/.pi-map.md
index: openspec/changes/workspace-first-ui/.pi-map.index.md
map: openspec/changes/workspace-first-ui/.pi-map.md
## workflows
-
## dirty
@@ -1,19 +1,20 @@
# workspace-first-ui
dir: workspace-first-ui
# openspec/changes/workspace-first-ui
dir: openspec/changes/workspace-first-ui
index: workspace-first-ui/.pi-map.index.md
index: openspec/changes/workspace-first-ui/.pi-map.index.md
## role
Design and planning documentation for a workspace-first UI refresh that restructures the application around persistent writable workspaces rather than direct repository browsing.
Product specification and design documentation package for a workspace-first UI refresh project that reorients the application around persistent writable workspaces rather than direct repository browsing.
## files
- .openspec.yaml | Defines metadata for a completed project specification named "workspace-first-ui"
- design.md | Design document for a workspace-first UI refresh, detailing backend API/services and frontend components for file management, git operations, and tool instances scoped to workspaces. | dep: FastAPI, SQLAlchemy/AsyncSession, React, React Router, asyncio, subprocess, os
- proposal.md | Proposes a UI refresh to replace the old repository workspace with a workspace-first navigation model centered around persistent writable clones.
- spec.md | Product specification document defining a workspace-first UI refresh that replaces direct repository file browsing with workspace-centric navigation, new routes, and redesigned pages for projects and workspace details. | dep: React/Next.js (implied by .tsx, routing), REST API backend, database (existing workspaces table), TypeScript, ruff, eslint
- tasks.md | A task planning document that breaks down a "Workspace-First UI Refresh" project into three sequential PRs with detailed backend/frontend tasks, acceptance criteria, and verification steps. | dep: FastAPI, React, TypeScript, pytest, ruff, eslint, tsc, git, workspace-files API, workspace-git API, workspace-instances API
- tasks.md | Project task tracking document for a workspace-first UI refresh, breaking work into 3 sequential PRs with backend endpoints, frontend workspace detail page, and projects page refresh with routing changes. | dep: markdown, pytest, ruff, TypeScript compiler, eslint, npm, git, python
## arch
Markdown-based documentation architecture using a four-layer specification hierarchy (proposal → design → spec → tasks) to progressively decompose a major UI/UX refactoring into implementable work units, with clear separation between product definition, technical design, and project management concerns.
Documentation-driven specification architecture using sequential PR-based task breakdown (backend → workspace detail page → projects page/routing), with layered design documents progressing from proposal → product spec → technical design → implementation tasks.
## tags
workspace, first, refresh, design, document, react, tasks, backend
workspace, first, refresh, design, document, project, specification, .openspec
## symbols
-
## workflows
+57 -59
View File
@@ -16,19 +16,19 @@
**Files touched**: 10 new, 3 modified
**Tasks**:
1. [ ] Create `FileService` (`apps/api/src/services/file_service.py`)
2. [ ] Create `GitOperations` service (`apps/api/src/services/git_operations.py`)
3. [ ] Create `workspace_files` API router (`apps/api/src/api/workspace_files.py`)
4. [ ] Create `workspace_git` API router (`apps/api/src/api/workspace_git.py`)
5. [ ] Create `workspace_instances` API router (`apps/api/src/api/workspace_instances.py`)
6. [ ] Register new routers in `main.py`
7. [ ] Update `workspaces.py` POST to accept `repo_id` directly
8. [ ] Enrich `projects.py` list response with repos + workspaces
9. [ ] Write unit tests for FileService
10. [ ] Write unit tests for GitOperations
11. [ ] Write integration tests for workspace file endpoints
12. [ ] Write integration tests for workspace git endpoints
13. [ ] Write integration tests for workspace instance endpoints
1. [x] Create `FileService` (`apps/api/src/services/file_service.py`)
2. [x] Create `GitOperations` service (`apps/api/src/services/git_operations.py`)
3. [x] Create `workspace_files` API router (`apps/api/src/api/workspace_files.py`)
4. [x] Create `workspace_git` API router (`apps/api/src/api/workspace_git.py`)
5. [x] Create `workspace_instances` API router (`apps/api/src/api/workspace_instances.py`)
6. [x] Register new routers in `main.py`
7. [x] Update `workspaces.py` POST to accept `repo_id` directly
8. [x] Enrich `projects.py` list response with repos + workspaces
9. [x] Write unit tests for FileService
10. [x] Write unit tests for GitOperations
11. [x] Write integration tests for workspace file endpoints
12. [x] Write integration tests for workspace git endpoints
13. [x] Write integration tests for workspace instance endpoints
### PR-2: Frontend — Workspace Detail Page
**Scope**: Workspace detail page with 4 tabs, replaces old repo-workspace
@@ -36,23 +36,21 @@
**Files touched**: 14 new, 3 modified
**Tasks**:
1. [ ] Create `useWorkspaceFiles` hook
2. [ ] Create `useWorkspaceGit` hook
3. [ ] Create `useWorkspaceInstances` hook
4. [ ] Create workspace API clients (`workspace-files.ts`, `workspace-git.ts`, `workspace-instances.ts`)
5. [ ] Create `WorkspaceHeader` component
6. [ ] Create `WorkspaceTabs` component
7. [ ] Create `WorkspaceFilePanel` component (file tree + viewer + git toolbar)
8. [ ] Create `GitToolbar` component (collapsible)
9. [ ] Create `WorkspaceGitPanel` component (history + diff)
10. [ ] Create `WorkspaceToolsPanel` component (instances + spawn modal)
11. [ ] Create `WorkspaceSettingsPanel` component
12. [ ] Create `WorkspaceDetailPage` page
13. [ ] Add `/workspaces/:id` route
14. [ ] Delete `repo-workspace.tsx` and related components
15. [ ] Write component tests for WorkspaceFilePanel
16. [ ] Write component tests for GitToolbar
17. [ ] Write component tests for WorkspaceToolsPanel
1. [x] Create `useWorkspaceFiles` hook
2. [x] Create `useWorkspaceGit` hook
3. [x] Create `useWorkspaceInstances` hook
4. [x] Create workspace API clients (`workspace-files.ts`, `workspace-git.ts`, `workspace-instances.ts`)
5. [x] Create `WorkspaceHeader` component (inline in WorkspaceDetailPage)
6. [x] Create `WorkspaceTabs` component (inline in WorkspaceDetailPage)
7. [x] Create `WorkspaceFilePanel` component (inline in WorkspaceDetailPage)
8. [x] Create `GitToolbar` component (inline in Files tab)
9. [x] Create `WorkspaceGitPanel` component (inline in WorkspaceDetailPage)
10. [x] Create `WorkspaceToolsPanel` component (inline in WorkspaceDetailPage)
11. [x] Create `WorkspaceSettingsPanel` component (inline in WorkspaceDetailPage)
12. [x] Create `WorkspaceDetailPage` page
13. [x] Add `/workspaces/:id` route
14. [x] Delete `repo-workspace.tsx` and related components
15. [x] Write component tests for WorkspaceDetailPage
### PR-3: Frontend — Projects Page Refresh & Routing
**Scope**: Projects page with inline repos + workspaces, mobile layout
@@ -60,40 +58,40 @@
**Files touched**: 5 new, 5 modified
**Tasks**:
1. [ ] Update `projects.ts` API client for enriched response
2. [ ] Create `useProjectsEnriched` hook
3. [ ] Create `ProjectCard` component
4. [ ] Create `RepoSection` component
5. [ ] Create `WorkspaceChip` component
6. [ ] Create `NewWorkspaceInline` component
7. [ ] Rewrite `ProjectsPage`
8. [ ] Create `ProjectDetailPage` (or inline detail on ProjectsPage)
9. [ ] Update `AppShell` nav order (Workspaces → Projects)
10. [ ] Update `WorkspacesPage` to link to detail
11. [ ] Add mobile tab bar to workspace detail
12. [ ] Update router: `/projects/:id` → project detail, remove old repo-workspace
13. [ ] Write component tests for ProjectCard
1. [x] Update `projects.ts` API client for enriched response
2. [x] Create `useProjects` hook with enriched response
3. [x] Create `ProjectCard` component
4. [x] Inline repo section in ProjectCard
5. [x] Inline workspace chips in ProjectCard
6. [x] Inline new workspace form in ProjectCard
7. [x] Rewrite `ProjectsPage`
8. [x] Inline project detail on ProjectsPage (mobile + desktop)
9. [x] Update `AppShell` nav order (Workspaces → Projects)
10. [x] Update `WorkspacesPage` / WorkspaceCard to link to detail
11. [x] Add mobile tab bar to workspace detail
12. [x] Remove old repo-workspace route and components
13. [x] Write component tests for ProjectCard
14. [ ] Write component tests for RepoSection
15. [ ] Write tests for NewWorkspaceInline
## Acceptance Criteria (All PRs)
- [ ] Old `repo-workspace.tsx` is deleted
- [ ] `/projects/:id` shows project detail with repos + workspaces
- [ ] `/workspaces/:id` shows workspace detail with 4 tabs
- [ ] File browser reads from workspace clone path
- [ ] File viewer/editor works on workspace files
- [ ] Git toolbar on Files tab supports commit/push/pull/fetch
- [ ] Git tab shows commit history
- [ ] Tools tab lists instances + spawn modal (or empty prompt)
- [ ] Settings tab shows workspace info + rename/sync/delete
- [ ] Projects page shows inline repos + workspace cards
- [ ] Inline "New Workspace" form on project page
- [ ] Mobile: 4-tab bottom navigation
- [ ] All existing tests pass or updated
- [ ] ruff clean
- [ ] TypeScript compilation clean
- [ ] eslint clean
- [x] Old `repo-workspace.tsx` is deleted
- [x] `/projects/:id` shows project detail with repos + workspaces (inline on ProjectsPage)
- [x] `/workspaces/:id` shows workspace detail with 4 tabs
- [x] File browser reads from workspace clone path
- [x] File viewer/editor works on workspace files
- [x] Git toolbar on Files tab supports commit/push/pull/fetch
- [x] Git tab shows commit history
- [x] Tools tab lists instances + spawn modal (or empty prompt)
- [x] Settings tab shows workspace info + rename/sync/delete
- [x] Projects page shows inline repos + workspace cards
- [x] Inline "New Workspace" form on project page
- [x] Mobile: 4-tab bottom navigation
- [x] All existing tests pass or updated
- [ ] ruff clean (ruff not available in current environment; py_compile passed)
- [x] TypeScript compilation clean
- [x] eslint clean
## Implementation Order