Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc72c5f6e9 | |||
| 51a399c775 | |||
| fc75eeb76d | |||
| 37134b8c18 | |||
| c6b804bf0a | |||
| c754984df8 | |||
| 906aab3b73 | |||
| e1aaf9f6fc | |||
| 6170306d9e | |||
| 04cd9ff472 | |||
| 1bf42a7feb | |||
| 56dd7d3fd3 | |||
| 8837031fd2 | |||
| a0cfbbc2d2 | |||
| 280a6ff2fa | |||
| 78e808bc54 | |||
| c1445976d7 | |||
| ff8aa2a4f5 | |||
| 398436ecb5 | |||
| 06a4a27880 | |||
| f34c733706 | |||
| 95efa5d029 | |||
| 9a036f1968 | |||
| ec6d4ad496 | |||
| d70b8e2363 | |||
| ee3c5af7a4 | |||
| b02cd978c3 | |||
| e956d7c30d | |||
| b8fc4e6642 | |||
| ab1843b1c3 | |||
| 88a973dc68 | |||
| 27c77af591 | |||
| e7587ca9f5 | |||
| 59b125d8e2 | |||
| b05de96569 | |||
| a5d64d1859 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"fingerprint": "fdea8a74bb4c7449c01c4bd61646c895b10ede78"
|
||||
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812"
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
||||
|
||||
Last updated: 2026-05-28
|
||||
Last updated: 2026-06-02
|
||||
|
||||
## Sources scanned
|
||||
|
||||
@@ -21,7 +21,6 @@ Last updated: 2026-05-28
|
||||
| Skill | Trigger / description | Scope | Path |
|
||||
| --- | --- | --- | --- |
|
||||
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
|
||||
| `openspec` | Use OpenSpec as the source of truth for planning, implementation, verification, and archive discipline. | user | `/home/alex/.config/opencode/skills/openspec/SKILL.md` |
|
||||
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
|
||||
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
|
||||
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
|
||||
|
||||
@@ -75,6 +75,7 @@ Do not:
|
||||
* Introduce new dependencies without clear justification.
|
||||
* Treat existing code as more authoritative than OpenSpec for intended behavior.
|
||||
* Decide product behavior silently when the spec is unclear.
|
||||
* Run `docker compose` commands (build, up, down, etc.) without explicit user approval and proper isolation (e.g., feature branches, separate worktrees, or staged rollouts). Docker Compose operations are deployment-level changes that can affect running services, shared volumes, and network state. Always ask first.
|
||||
|
||||
If scope must change, propose an OpenSpec update first.
|
||||
|
||||
|
||||
+2
-2
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
|
||||
# Copy application code
|
||||
COPY --chown=appuser:appgroup . .
|
||||
|
||||
# Create directories for repo and instance storage
|
||||
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
||||
# Create directories for repo, instance, and workspace storage
|
||||
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
|
||||
|
||||
# Copy wait-for-db script
|
||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||
|
||||
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
# In-memory connection counter per user (single-process assumption)
|
||||
_connection_counts: dict[uuid.UUID, int] = {}
|
||||
MAX_CONNECTIONS_PER_USER = 5
|
||||
MAX_CONNECTIONS_PER_USER = 20
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
|
||||
@@ -10,7 +10,12 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.config import Settings
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.ssh_key import SSHKey
|
||||
@@ -90,7 +95,9 @@ def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
|
||||
return env, key_path
|
||||
|
||||
|
||||
def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
|
||||
def _preflight_remote_repository(
|
||||
remote_url: str, ssh_key: SSHKey | None = None
|
||||
) -> None:
|
||||
"""Verify a remote repository is reachable before cloning."""
|
||||
env = None
|
||||
key_path = None
|
||||
@@ -109,22 +116,32 @@ def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None)
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="remote repository check timed out",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="git command not found",
|
||||
)
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
|
||||
logger.error(
|
||||
"Preflight check failed for %s: stderr=%s", remote_url, result.stderr
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"repository not found or inaccessible: {result.stderr}",
|
||||
)
|
||||
|
||||
|
||||
def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
|
||||
def _clone_working_repository(
|
||||
remote_url: str, repo_path: str, ssh_key: SSHKey | None = None
|
||||
) -> None:
|
||||
env = None
|
||||
key_path = None
|
||||
|
||||
@@ -142,9 +159,14 @@ def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey |
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="git command not found",
|
||||
)
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
@@ -165,7 +187,10 @@ def _init_working_repository(repo_path: str) -> None:
|
||||
text=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="git command not found",
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return
|
||||
@@ -310,7 +335,10 @@ async def create_external_repository(
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="repository name already exists",
|
||||
)
|
||||
|
||||
# Validate and potentially correct the URL
|
||||
remote_url = data.remote_url
|
||||
@@ -336,13 +364,21 @@ async def create_external_repository(
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="invalid ssh_key_id format",
|
||||
)
|
||||
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||
)
|
||||
if ssh_key.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="ssh key does not belong to user",
|
||||
)
|
||||
|
||||
if remote_url:
|
||||
_preflight_remote_repository(remote_url, ssh_key)
|
||||
@@ -369,7 +405,10 @@ async def create_external_repository(
|
||||
repo.is_mirror = False
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to clone repository: {exc}",
|
||||
)
|
||||
else:
|
||||
# Initialize empty repo
|
||||
os.makedirs(repo_path, exist_ok=True)
|
||||
@@ -438,7 +477,9 @@ async def delete_repository(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
# Remove from disk
|
||||
if os.path.exists(repo.path):
|
||||
@@ -484,7 +525,10 @@ async def create_repository(
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="repository name already exists",
|
||||
)
|
||||
|
||||
# Validate and potentially correct the URL
|
||||
remote_url = data.remote_url
|
||||
@@ -511,13 +555,21 @@ async def create_repository(
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="invalid ssh_key_id format",
|
||||
)
|
||||
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||
)
|
||||
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="ssh key does not belong to user or project",
|
||||
)
|
||||
|
||||
if remote_url:
|
||||
_preflight_remote_repository(remote_url, ssh_key)
|
||||
@@ -581,20 +633,30 @@ async def update_repository_ssh_key(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
# Validate SSH key if provided
|
||||
if data.ssh_key_id:
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="invalid ssh_key_id format",
|
||||
)
|
||||
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||
)
|
||||
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="ssh key does not belong to user or project",
|
||||
)
|
||||
|
||||
repo.ssh_key_id = ssh_key_id
|
||||
else:
|
||||
@@ -640,16 +702,24 @@ async def get_repository_history(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
history = get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
|
||||
history = get_commit_history(
|
||||
repo.path, branch=branch, limit=limit, offset=offset
|
||||
)
|
||||
return history
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -681,10 +751,14 @@ async def get_repository_commit(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
detail = get_commit_detail(repo.path, commit_hash)
|
||||
@@ -763,10 +837,14 @@ async def list_repository_files(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
entries = list_tree(repo.path, branch=branch, path=path)
|
||||
@@ -829,10 +907,14 @@ async def get_repository_file_content(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
file_content = get_file_content(repo.path, branch=branch, path=path)
|
||||
@@ -847,7 +929,9 @@ async def get_repository_file_content(
|
||||
last_commit=file_content.last_commit,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="file not found"
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
@@ -880,32 +964,104 @@ async def get_repository_branches(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
|
||||
try:
|
||||
branches, default_branch = list_branches(repo.path)
|
||||
return BranchesResponse(
|
||||
branches=[
|
||||
{
|
||||
"name": b.name,
|
||||
"is_default": b.is_default,
|
||||
"last_commit": b.last_commit,
|
||||
}
|
||||
for b in branches
|
||||
],
|
||||
default_branch=default_branch,
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error(
|
||||
"Failed to list branches for repo %s: %s",
|
||||
repo_id,
|
||||
str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
# Try local repo first (.git subdir for normal repos, HEAD for bare)
|
||||
is_valid_git_repo = os.path.isdir(
|
||||
os.path.join(repo.path, ".git")
|
||||
) or os.path.isfile(os.path.join(repo.path, "HEAD"))
|
||||
|
||||
if is_valid_git_repo:
|
||||
try:
|
||||
branches, default_branch = list_branches(repo.path)
|
||||
return BranchesResponse(
|
||||
branches=[
|
||||
{
|
||||
"name": b.name,
|
||||
"is_default": b.is_default,
|
||||
"last_commit": b.last_commit,
|
||||
}
|
||||
for b in branches
|
||||
],
|
||||
default_branch=default_branch,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error(
|
||||
"Failed to list branches for repo %s: %s",
|
||||
repo_id,
|
||||
str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
||||
) from e
|
||||
|
||||
# Local repo missing/corrupt — try remote if available
|
||||
if repo.remote_url:
|
||||
ssh_key = None
|
||||
if repo.ssh_key_id:
|
||||
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||
|
||||
ssh_result = _prepare_ssh_env(ssh_key)
|
||||
env = None
|
||||
key_path = None
|
||||
if ssh_result:
|
||||
env, key_path = ssh_result
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "ls-remote", "--heads", repo.remote_url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
remote_branches = []
|
||||
default_branch = "main"
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line:
|
||||
parts = line.split("\t")
|
||||
if len(parts) == 2:
|
||||
ref = parts[1]
|
||||
if ref.startswith("refs/heads/"):
|
||||
branch_name = ref[len("refs/heads/") :]
|
||||
remote_branches.append(branch_name)
|
||||
if branch_name in ("main", "master"):
|
||||
default_branch = branch_name
|
||||
if remote_branches:
|
||||
return BranchesResponse(
|
||||
branches=[
|
||||
{
|
||||
"name": b,
|
||||
"is_default": b == default_branch,
|
||||
"last_commit": None,
|
||||
}
|
||||
for b in remote_branches
|
||||
],
|
||||
default_branch=default_branch,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"ls-remote returned %d for repo %s: %s",
|
||||
result.returncode,
|
||||
repo_id,
|
||||
result.stderr,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("ls-remote timed out for repo %s", repo_id)
|
||||
except Exception as e:
|
||||
logger.warning("ls-remote failed for repo %s: %s", repo_id, str(e))
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="repository not found on disk — re-clone or re-create the repository",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -938,10 +1094,14 @@ async def update_repository_file(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
# Get user info for commit
|
||||
user = await _get_user(session, user_id)
|
||||
@@ -1009,10 +1169,14 @@ async def get_repository_status(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
status_result = get_status(repo.path)
|
||||
@@ -1068,10 +1232,14 @@ async def create_repository_branch(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
create_branch(repo.path, data.name, data.base_branch)
|
||||
@@ -1111,10 +1279,14 @@ async def delete_repository_branch(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
delete_branch(repo.path, branch_name, force)
|
||||
@@ -1152,10 +1324,14 @@ async def checkout_repository_branch(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
checkout_branch(repo.path, data.branch)
|
||||
@@ -1204,10 +1380,14 @@ async def commit_repository_changes(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
# Get user info for commit
|
||||
user = await _get_user(session, user_id)
|
||||
@@ -1262,10 +1442,14 @@ async def fetch_repository(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
fetch(repo.path)
|
||||
@@ -1308,10 +1492,14 @@ async def pull_repository(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
pull(repo.path, branch)
|
||||
@@ -1354,10 +1542,14 @@ async def push_repository(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
push(repo.path, branch)
|
||||
@@ -1407,10 +1599,14 @@ async def merge_repository_branches(
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||
)
|
||||
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||
)
|
||||
|
||||
try:
|
||||
commit_hash = merge(
|
||||
|
||||
@@ -4,13 +4,19 @@ import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.tool_instance import ToolInstance
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
@@ -76,26 +82,77 @@ async def create_project(
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[ProjectResponse],
|
||||
summary="List all projects",
|
||||
description="Retrieve all projects owned by the authenticated user.",
|
||||
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
|
||||
)
|
||||
async def list_projects(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[Project]:
|
||||
) -> list[dict]:
|
||||
"""List all projects for the authenticated user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
List of projects owned by the user.
|
||||
Returns projects with nested repositories and workspaces for inline display.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
||||
return list(result.scalars().all())
|
||||
result = await session.execute(
|
||||
select(Project)
|
||||
.where(Project.owner_id == user.id)
|
||||
.order_by(Project.created_at.desc())
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
enriched = []
|
||||
for project in projects:
|
||||
repos_result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project.id)
|
||||
)
|
||||
repositories = []
|
||||
for repo in repos_result.scalars().all():
|
||||
ws_result = await session.execute(
|
||||
select(Workspace).where(Workspace.repo_id == repo.id)
|
||||
)
|
||||
workspaces = []
|
||||
for ws in ws_result.scalars().all():
|
||||
# Count instances
|
||||
inst_result = await session.execute(
|
||||
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
||||
)
|
||||
instance_count = inst_result.scalar() or 0
|
||||
workspaces.append(
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"branch": ws.branch,
|
||||
"status": ws.status,
|
||||
"instance_count": instance_count,
|
||||
}
|
||||
)
|
||||
|
||||
repositories.append(
|
||||
{
|
||||
"id": str(repo.id),
|
||||
"name": repo.name,
|
||||
"remote_url": repo.remote_url,
|
||||
"workspaces": workspaces,
|
||||
}
|
||||
)
|
||||
|
||||
enriched.append(
|
||||
{
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"owner_id": str(project.owner_id),
|
||||
"repositories": repositories,
|
||||
"created_at": project.created_at.isoformat()
|
||||
if project.created_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return enriched
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -184,7 +241,9 @@ async def delete_project(
|
||||
project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
# Delete repositories from disk and database
|
||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||
)
|
||||
repositories = result.scalars().all()
|
||||
for repo in repositories:
|
||||
if os.path.exists(repo.path):
|
||||
|
||||
@@ -222,8 +222,7 @@ async def _handle_terminal_websocket(
|
||||
# Use mutable session reference so loops can survive reset
|
||||
session_ref = SessionRef(session, slot_session_id)
|
||||
|
||||
# Start I/O loops and heartbeat
|
||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
|
||||
write_task = asyncio.create_task(
|
||||
_write_loop(session_ref, websocket, instance_id)
|
||||
)
|
||||
@@ -232,7 +231,7 @@ async def _handle_terminal_websocket(
|
||||
|
||||
# Wait for either task to complete (indicating disconnect or error)
|
||||
done, pending = await asyncio.wait(
|
||||
[read_task, write_task, heartbeat_task],
|
||||
[write_task, heartbeat_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
@@ -267,28 +266,6 @@ async def _handle_terminal_websocket(
|
||||
)
|
||||
|
||||
|
||||
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
||||
"""Read output from the container and send to WebSocket."""
|
||||
try:
|
||||
while True:
|
||||
session = session_ref.session
|
||||
if not session.is_alive() or session._closed:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
data = await session.read_output()
|
||||
if data:
|
||||
try:
|
||||
await websocket.send_bytes(data)
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
else:
|
||||
await asyncio.sleep(0.01)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||
"""Read input from WebSocket and send to container."""
|
||||
try:
|
||||
@@ -319,6 +296,10 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
||||
rows,
|
||||
)
|
||||
await session.resize(cols, rows)
|
||||
elif msg_type == "ack":
|
||||
char_count = ctrl.get("chars", 0)
|
||||
if char_count > 0:
|
||||
session.acknowledge_data(char_count)
|
||||
elif msg_type == "reset":
|
||||
# Reset terminal session (scoped to current slot)
|
||||
logger.debug(
|
||||
|
||||
@@ -1039,7 +1039,7 @@ services:
|
||||
write_compose_file(instance_dir, compose_content)
|
||||
|
||||
elif tool_type.definition_type == "manifest":
|
||||
# Manifest-based: build image and generate compose
|
||||
# Manifest-based: generate compose only; image built lazily on start
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
|
||||
manifest_def = await session.get(
|
||||
@@ -1061,44 +1061,8 @@ services:
|
||||
deep_merge(dict(base_def.manifest), manifest)
|
||||
)
|
||||
|
||||
# Determine home directory for path expansion
|
||||
_home_dir = get_manifest_home_dir(manifest)
|
||||
|
||||
image_tag = compute_image_tag(tool_type.name, manifest)
|
||||
|
||||
# Build image during creation so start is fast
|
||||
dockerfile = compile_dockerfile(manifest)
|
||||
entrypoint = compile_entrypoint(manifest)
|
||||
build_ctx = {
|
||||
"Dockerfile": dockerfile,
|
||||
".headquarter/entrypoint.sh": entrypoint,
|
||||
}
|
||||
|
||||
returncode, stdout, stderr = await asyncio.to_thread(
|
||||
build_image,
|
||||
instance_dir=instance_dir,
|
||||
dockerfile=dockerfile,
|
||||
tag=image_tag,
|
||||
build_context=build_ctx,
|
||||
)
|
||||
|
||||
if returncode != 0:
|
||||
logger.error(
|
||||
"Failed to build image for manifest instance %s: %s",
|
||||
instance_name,
|
||||
stderr,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to build Docker image: {stderr[:500]}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Built manifest image %s for instance %s",
|
||||
image_tag,
|
||||
instance_name,
|
||||
)
|
||||
|
||||
variables = {
|
||||
"IMAGE_TAG": image_tag,
|
||||
"INSTANCE_NAME": instance_name.lower(),
|
||||
@@ -1466,6 +1430,18 @@ async def _prepare_manifest_instance(
|
||||
|
||||
compose_content = compile_compose(manifest, variables)
|
||||
|
||||
logger.debug(
|
||||
"_prepare_manifest_instance for %s: repo_path=%s compose_volumes=%s",
|
||||
instance.id,
|
||||
repo_path or "<empty>",
|
||||
manifest.get("mounts", []),
|
||||
)
|
||||
logger.debug(
|
||||
"Generated compose for %s:\n%s",
|
||||
instance.id,
|
||||
compose_content,
|
||||
)
|
||||
|
||||
# Cache
|
||||
instance.image_tag = image_tag
|
||||
instance.manifest_compiled_at = datetime.now()
|
||||
@@ -1635,38 +1611,14 @@ async def start_instance(
|
||||
|
||||
# Mount selected SSH keys into container home dir
|
||||
if instance.ssh_key_ids:
|
||||
from src.services.ssh_keys import write_ssh_config, _sanitize_filename
|
||||
|
||||
# Collect all valid keys first
|
||||
ssh_keys_to_mount = []
|
||||
for key_id in instance.ssh_key_ids:
|
||||
ssh_key = await session.get(SSHKey, uuid.UUID(key_id))
|
||||
if ssh_key and ssh_key.user_id == user_id:
|
||||
try:
|
||||
ssh_dir = prepare_ssh_key_files(
|
||||
instance_dir,
|
||||
ssh_key,
|
||||
subdir=f"mounts/ssh/{key_id}/.ssh",
|
||||
uid=container_uid,
|
||||
gid=container_gid,
|
||||
)
|
||||
ssh_target = os.path.join(home_dir, ".ssh")
|
||||
extra_volumes.append(
|
||||
{
|
||||
"source": ssh_dir,
|
||||
"target": ssh_target,
|
||||
"type": "bind",
|
||||
}
|
||||
)
|
||||
logger.debug(
|
||||
"Mounted SSH key %s for instance %s to %s",
|
||||
ssh_key.name,
|
||||
instance.id,
|
||||
ssh_target,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to prepare SSH key %s for instance %s: %s",
|
||||
key_id,
|
||||
instance.id,
|
||||
exc,
|
||||
)
|
||||
ssh_keys_to_mount.append(ssh_key)
|
||||
else:
|
||||
logger.warning(
|
||||
"SSH key %s not found or not authorized for user %s",
|
||||
@@ -1674,6 +1626,79 @@ async def start_instance(
|
||||
user_id,
|
||||
)
|
||||
|
||||
if ssh_keys_to_mount:
|
||||
# Use a single shared .ssh directory so all keys are visible
|
||||
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
|
||||
os.makedirs(ssh_dir, exist_ok=True)
|
||||
|
||||
key_filenames = []
|
||||
for ssh_key in ssh_keys_to_mount:
|
||||
# Use sanitized key name as filename prefix to avoid collisions
|
||||
key_name = _sanitize_filename(ssh_key.name)
|
||||
# If multiple keys have the same name, append a short hash
|
||||
base_filename = f"id_ed25519_{key_name}"
|
||||
filename = base_filename
|
||||
counter = 1
|
||||
while filename in key_filenames:
|
||||
filename = f"{base_filename}_{counter}"
|
||||
counter += 1
|
||||
key_filenames.append(filename)
|
||||
|
||||
try:
|
||||
prepare_ssh_key_files(
|
||||
instance_dir,
|
||||
ssh_key,
|
||||
subdir="mounts/ssh/.ssh",
|
||||
uid=container_uid,
|
||||
gid=container_gid,
|
||||
key_filename=filename,
|
||||
write_config=False,
|
||||
)
|
||||
logger.debug(
|
||||
"Prepared SSH key %s as %s for instance %s",
|
||||
ssh_key.name,
|
||||
filename,
|
||||
instance.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to prepare SSH key %s for instance %s: %s",
|
||||
ssh_key.id,
|
||||
instance.id,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Write combined SSH config with all keys
|
||||
try:
|
||||
write_ssh_config(
|
||||
ssh_dir,
|
||||
key_filenames,
|
||||
uid=container_uid,
|
||||
gid=container_gid,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to write SSH config for instance %s: %s",
|
||||
instance.id,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Mount the single .ssh directory into container home
|
||||
ssh_target = os.path.join(home_dir, ".ssh")
|
||||
extra_volumes.append(
|
||||
{
|
||||
"source": ssh_dir,
|
||||
"target": ssh_target,
|
||||
"type": "bind",
|
||||
}
|
||||
)
|
||||
logger.debug(
|
||||
"Mounted %d SSH key(s) for instance %s to %s",
|
||||
len(ssh_keys_to_mount),
|
||||
instance.id,
|
||||
ssh_target,
|
||||
)
|
||||
|
||||
# ── MANIFEST-BASED FLOW ──────────────────────────────────────
|
||||
resolved_manifest = None
|
||||
|
||||
@@ -1684,6 +1709,7 @@ async def start_instance(
|
||||
repo_path = ""
|
||||
if instance.workspace_id:
|
||||
from src.models.workspace import Workspace as WorkspaceModel
|
||||
|
||||
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
||||
if workspace:
|
||||
repo_path = workspace.path
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Workspace file API endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.workspace import Workspace
|
||||
from src.services.file_service import FileService
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
|
||||
|
||||
|
||||
async def _get_workspace(
|
||||
session: AsyncSession,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> Workspace:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.id == workspace_id,
|
||||
Workspace.user_id == user_id,
|
||||
)
|
||||
)
|
||||
workspace = result.scalar_one_or_none()
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
return workspace
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_files(
|
||||
workspace_id: uuid.UUID,
|
||||
path: str = "",
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List files in a workspace directory."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
service = FileService()
|
||||
try:
|
||||
entries = service.list_directory(workspace, path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"name": e.name,
|
||||
"path": e.path,
|
||||
"type": e.type,
|
||||
"size": e.size,
|
||||
}
|
||||
for e in entries
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/content")
|
||||
async def get_file_content(
|
||||
workspace_id: uuid.UUID,
|
||||
path: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get the content of a text file."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
service = FileService()
|
||||
try:
|
||||
content = service.read_file(workspace, path)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return {"content": content, "path": path}
|
||||
|
||||
|
||||
@router.post("/content")
|
||||
async def write_file(
|
||||
workspace_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Write a file and optionally commit."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
service = FileService()
|
||||
|
||||
file_path = data.get("path", "").strip()
|
||||
content = data.get("content", "")
|
||||
commit_message = data.get("message", "").strip()
|
||||
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=400, detail="File path is required")
|
||||
|
||||
try:
|
||||
service.write_file(workspace, file_path, content)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
if commit_message:
|
||||
from src.services.git_operations import GitOperations
|
||||
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.commit(commit_message)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "saved", "path": file_path}
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Workspace git API endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.workspace import Workspace
|
||||
from src.services.git_operations import GitOperations
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
|
||||
|
||||
|
||||
async def _get_workspace(
|
||||
session: AsyncSession,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> Workspace:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.id == workspace_id,
|
||||
Workspace.user_id == user_id,
|
||||
)
|
||||
)
|
||||
workspace = result.scalar_one_or_none()
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
return workspace
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def git_status(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get git status for the workspace."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
status = await git.status()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"branch": status.branch,
|
||||
"modified": status.modified,
|
||||
"added": status.added,
|
||||
"deleted": status.deleted,
|
||||
"untracked": status.untracked,
|
||||
"ahead": status.ahead,
|
||||
"behind": status.behind,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/branches")
|
||||
async def git_branches(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List branches for the workspace."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
branches, current = await git.branches()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"branches": branches,
|
||||
"current_branch": current,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/commit")
|
||||
async def git_commit(
|
||||
workspace_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Stage all changes and commit."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
message = data.get("message", "").strip()
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="Commit message is required")
|
||||
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.commit(message)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "committed"}
|
||||
|
||||
|
||||
@router.post("/push")
|
||||
async def git_push(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Push current branch."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.push()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "pushed"}
|
||||
|
||||
|
||||
@router.post("/pull")
|
||||
async def git_pull(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Pull current branch."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.pull()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "pulled"}
|
||||
|
||||
|
||||
@router.post("/fetch")
|
||||
async def git_fetch(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Fetch from origin."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.fetch()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "fetched"}
|
||||
|
||||
|
||||
@router.post("/checkout")
|
||||
async def git_checkout(
|
||||
workspace_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Checkout a branch."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
branch = data.get("branch", "").strip()
|
||||
if not branch:
|
||||
raise HTTPException(status_code=400, detail="Branch name is required")
|
||||
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.checkout(branch)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
workspace.branch = branch
|
||||
await session.commit()
|
||||
|
||||
return {"status": "checked_out", "branch": branch}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def git_history(
|
||||
workspace_id: uuid.UUID,
|
||||
path: str | None = None,
|
||||
limit: int = 50,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get commit history."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
commits = await git.history(path, limit)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"commits": [
|
||||
{
|
||||
"hash": c.hash,
|
||||
"message": c.message,
|
||||
"author": c.author,
|
||||
"date": c.date,
|
||||
}
|
||||
for c in commits
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Workspace instance API endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
|
||||
|
||||
|
||||
async def _get_workspace(
|
||||
session: AsyncSession,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> Workspace:
|
||||
result = await session.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.id == workspace_id,
|
||||
Workspace.user_id == user_id,
|
||||
)
|
||||
)
|
||||
workspace = result.scalar_one_or_none()
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
return workspace
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_workspace_instances(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
"""List tool instances using this workspace."""
|
||||
await _get_workspace(session, workspace_id, user_id)
|
||||
result = await session.execute(
|
||||
select(ToolInstance)
|
||||
.where(ToolInstance.workspace_id == workspace_id)
|
||||
.order_by(ToolInstance.created_at.desc())
|
||||
)
|
||||
instances = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(i.id),
|
||||
"name": i.name,
|
||||
"display_name": i.display_name,
|
||||
"status": i.status,
|
||||
"tool_type_id": str(i.tool_type_id),
|
||||
"url": i.url,
|
||||
"port": i.port,
|
||||
"created_at": i.created_at.isoformat() if i.created_at else None,
|
||||
}
|
||||
for i in instances
|
||||
]
|
||||
@@ -17,6 +17,144 @@ from src.services.workspace_manager import WorkspaceHasInstancesError, Workspace
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
||||
all_workspaces_router = APIRouter(prefix="/workspaces")
|
||||
|
||||
|
||||
@all_workspaces_router.get("/")
|
||||
async def list_all_workspaces(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
"""List all workspaces for the current user across all repos."""
|
||||
instance_count = (
|
||||
select(func.count(ToolInstance.id))
|
||||
.where(ToolInstance.workspace_id == Workspace.id)
|
||||
.correlate(Workspace)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(
|
||||
Workspace,
|
||||
GitRepository.name.label("repo_name"),
|
||||
GitRepository.project_id,
|
||||
GitRepository.ssh_key_id.label("repo_ssh_key_id"),
|
||||
instance_count.label("instance_count"),
|
||||
)
|
||||
.join(GitRepository, Workspace.repo_id == GitRepository.id)
|
||||
.where(Workspace.user_id == user_id)
|
||||
.order_by(Workspace.created_at.desc())
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"repo_id": str(ws.repo_id),
|
||||
"repo_name": repo_name or "",
|
||||
"repo_ssh_key_id": str(ssh_key_id) if ssh_key_id else None,
|
||||
"project_id": str(project_id) if project_id else "",
|
||||
"project_name": "",
|
||||
"user_id": str(ws.user_id),
|
||||
"branch": ws.branch,
|
||||
"path": ws.path,
|
||||
"status": ws.status,
|
||||
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
|
||||
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
||||
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
||||
"instance_count": count or 0,
|
||||
}
|
||||
for ws, repo_name, project_id, ssh_key_id, count in rows
|
||||
]
|
||||
|
||||
|
||||
@all_workspaces_router.delete("/{workspace_id}")
|
||||
async def delete_workspace_top_level(
|
||||
workspace_id: uuid.UUID,
|
||||
force: bool = Query(False),
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Delete a workspace via top-level path."""
|
||||
workspace = await session.get(Workspace, workspace_id)
|
||||
if not workspace or workspace.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
|
||||
manager = WorkspaceManager()
|
||||
try:
|
||||
await manager.delete(workspace, force=force, session=session)
|
||||
await session.commit()
|
||||
except WorkspaceHasInstancesError as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Workspace has running tool instances",
|
||||
"instances": exc.instances,
|
||||
},
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to delete workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to delete workspace"
|
||||
) from exc
|
||||
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@all_workspaces_router.post("/")
|
||||
async def create_workspace_top_level(
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a workspace directly (no nested project/repo path)."""
|
||||
repo_id_str = data.get("repo_id", "").strip()
|
||||
if not repo_id_str:
|
||||
raise HTTPException(status_code=400, detail="repo_id is required")
|
||||
|
||||
try:
|
||||
repo_id = uuid.UUID(repo_id_str)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if not repo or repo.owner_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
|
||||
name = data.get("name", "").strip()
|
||||
branch = data.get("branch", "main").strip()
|
||||
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Workspace name is required")
|
||||
|
||||
manager = WorkspaceManager()
|
||||
try:
|
||||
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||
session.add(workspace)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to create workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Workspace name already exists for this repository",
|
||||
) from exc
|
||||
|
||||
await session.refresh(workspace)
|
||||
return {
|
||||
"id": str(workspace.id),
|
||||
"name": workspace.name,
|
||||
"repo_id": str(workspace.repo_id),
|
||||
"branch": workspace.branch,
|
||||
"path": workspace.path,
|
||||
"status": workspace.status,
|
||||
"created_at": workspace.created_at.isoformat()
|
||||
if workspace.created_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
@@ -54,6 +192,8 @@ async def list_workspaces(
|
||||
"name": ws.name,
|
||||
"repo_id": str(ws.repo_id),
|
||||
"repo_name": repo.name,
|
||||
"repo_ssh_key_id": str(repo.ssh_key_id) if repo.ssh_key_id else None,
|
||||
"project_id": str(repo.project_id) if repo.project_id else "",
|
||||
"project_name": repo.project.name if repo.project else "",
|
||||
"user_id": str(ws.user_id),
|
||||
"branch": ws.branch,
|
||||
@@ -89,9 +229,15 @@ async def create_workspace(
|
||||
|
||||
manager = WorkspaceManager()
|
||||
try:
|
||||
workspace = await manager.create(repo, user_id, name, branch)
|
||||
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||
session.add(workspace)
|
||||
await session.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to create workspace: %s", exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to create workspace: %s", exc)
|
||||
@@ -108,7 +254,9 @@ async def create_workspace(
|
||||
"branch": workspace.branch,
|
||||
"path": workspace.path,
|
||||
"status": workspace.status,
|
||||
"created_at": workspace.created_at.isoformat() if workspace.created_at else None,
|
||||
"created_at": workspace.created_at.isoformat()
|
||||
if workspace.created_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -216,9 +364,7 @@ async def delete_workspace(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Workspace has running tool instances",
|
||||
"instances": [
|
||||
{"id": str(i.id), "name": i.name} for i in exc.instances
|
||||
],
|
||||
"instances": exc.instances,
|
||||
},
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
@@ -244,7 +390,7 @@ async def sync_workspace(
|
||||
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||
|
||||
manager = WorkspaceManager()
|
||||
result = await manager.sync(workspace)
|
||||
result = await manager.sync(workspace, session=session)
|
||||
|
||||
if result.branch_deleted:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -24,7 +24,10 @@ from src.api.tool_types import router as tool_types_router
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.user_config import router as user_config_router
|
||||
from src.api.users import router as users_router
|
||||
from src.api.workspaces import router as workspaces_router
|
||||
from src.api.workspace_files import router as workspace_files_router
|
||||
from src.api.workspace_git import router as workspace_git_router
|
||||
from src.api.workspace_instances import router as workspace_instances_router
|
||||
from src.api.workspaces import all_workspaces_router, router as workspaces_router
|
||||
from src.config import Settings
|
||||
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
||||
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||
@@ -160,5 +163,9 @@ app.include_router(instance_proxy_router)
|
||||
app.include_router(terminal_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(all_workspaces_router)
|
||||
app.include_router(workspaces_router)
|
||||
app.include_router(workspace_files_router)
|
||||
app.include_router(workspace_git_router)
|
||||
app.include_router(workspace_instances_router)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
@@ -12,6 +12,7 @@ from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -29,4 +30,5 @@ __all__ = [
|
||||
"ToolType",
|
||||
"User",
|
||||
"UserConfig",
|
||||
"Workspace",
|
||||
]
|
||||
|
||||
@@ -7,8 +7,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""File operations scoped to a workspace directory."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileEntry:
|
||||
"""A single file or directory entry."""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
type: str # "file" or "directory"
|
||||
size: int | None = None
|
||||
|
||||
|
||||
class FileService:
|
||||
"""Read and write files within a workspace directory."""
|
||||
|
||||
def list_directory(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
relative_path: str = "",
|
||||
) -> list[FileEntry]:
|
||||
"""List entries in a workspace directory.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to list files in.
|
||||
relative_path: Path relative to workspace root.
|
||||
|
||||
Returns:
|
||||
List of file entries sorted by name (directories first).
|
||||
"""
|
||||
abs_path = os.path.join(workspace.path, relative_path)
|
||||
abs_path = os.path.normpath(abs_path)
|
||||
|
||||
# Security: ensure we stay within workspace
|
||||
if not abs_path.startswith(os.path.normpath(workspace.path)):
|
||||
raise ValueError("Path escapes workspace directory")
|
||||
|
||||
if not os.path.exists(abs_path):
|
||||
return []
|
||||
|
||||
entries = []
|
||||
for item in sorted(os.listdir(abs_path)):
|
||||
full = os.path.join(abs_path, item)
|
||||
rel = os.path.join(relative_path, item) if relative_path else item
|
||||
is_dir = os.path.isdir(full)
|
||||
size = os.path.getsize(full) if os.path.isfile(full) else None
|
||||
entries.append(
|
||||
FileEntry(
|
||||
name=item,
|
||||
path=rel.replace("\\", "/"),
|
||||
type="directory" if is_dir else "file",
|
||||
size=size,
|
||||
)
|
||||
)
|
||||
|
||||
# Directories first, then files, both alphabetical
|
||||
entries.sort(key=lambda e: (0 if e.type == "directory" else 1, e.name.lower()))
|
||||
return entries
|
||||
|
||||
def read_file(self, workspace: Workspace, relative_path: str) -> str:
|
||||
"""Read a text file from the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to read from.
|
||||
relative_path: Path relative to workspace root.
|
||||
|
||||
Returns:
|
||||
File contents as string.
|
||||
|
||||
Raises:
|
||||
ValueError: If path escapes workspace or file is binary.
|
||||
FileNotFoundError: If file does not exist.
|
||||
"""
|
||||
abs_path = self._resolve_path(workspace, relative_path)
|
||||
|
||||
if not os.path.isfile(abs_path):
|
||||
raise FileNotFoundError(f"Not a file: {relative_path}")
|
||||
|
||||
# Basic binary check — read first 8KB and look for null bytes
|
||||
with open(abs_path, "rb") as f:
|
||||
chunk = f.read(8192)
|
||||
if b"\x00" in chunk:
|
||||
raise ValueError("Binary files cannot be viewed")
|
||||
|
||||
with open(abs_path, encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
|
||||
def write_file(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
relative_path: str,
|
||||
content: str,
|
||||
) -> None:
|
||||
"""Write a text file to the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to write to.
|
||||
relative_path: Path relative to workspace root.
|
||||
content: File contents.
|
||||
|
||||
Raises:
|
||||
ValueError: If path escapes workspace.
|
||||
"""
|
||||
abs_path = self._resolve_path(workspace, relative_path)
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
|
||||
with open(abs_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info("Wrote file %s in workspace %s", relative_path, workspace.id)
|
||||
|
||||
def _resolve_path(self, workspace: Workspace, relative_path: str) -> str:
|
||||
"""Resolve a relative path to absolute, with security check."""
|
||||
abs_path = os.path.normpath(os.path.join(workspace.path, relative_path))
|
||||
workspace_root = os.path.normpath(workspace.path)
|
||||
|
||||
if not abs_path.startswith(workspace_root):
|
||||
raise ValueError("Path escapes workspace directory")
|
||||
|
||||
return abs_path
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Git commands scoped to a workspace directory."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitStatus:
|
||||
"""Parsed git status output."""
|
||||
|
||||
branch: str
|
||||
modified: list[str]
|
||||
added: list[str]
|
||||
deleted: list[str]
|
||||
untracked: list[str]
|
||||
ahead: int = 0
|
||||
behind: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Commit:
|
||||
"""A single git commit."""
|
||||
|
||||
hash: str
|
||||
message: str
|
||||
author: str
|
||||
date: str
|
||||
|
||||
|
||||
class GitOperations:
|
||||
"""Run git commands within a workspace directory."""
|
||||
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self.cwd = workspace.path
|
||||
self.branch = workspace.branch
|
||||
|
||||
async def _run(self, *cmd: str) -> tuple[int, str, str]:
|
||||
"""Run a git command and return (returncode, stdout, stderr)."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
||||
|
||||
async def status(self) -> GitStatus:
|
||||
"""Get git status for the workspace."""
|
||||
returncode, stdout, _ = await self._run(
|
||||
"git", "-C", self.cwd, "status", "--porcelain", "-b"
|
||||
)
|
||||
|
||||
modified: list[str] = []
|
||||
added: list[str] = []
|
||||
deleted: list[str] = []
|
||||
untracked: list[str] = []
|
||||
branch = self.branch
|
||||
ahead = 0
|
||||
behind = 0
|
||||
|
||||
for line in stdout.splitlines():
|
||||
if line.startswith("##"):
|
||||
# Branch info line
|
||||
branch_info = line[3:].strip()
|
||||
if "..." in branch_info:
|
||||
branch = branch_info.split("...")[0]
|
||||
if "[ahead " in branch_info:
|
||||
ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
|
||||
ahead = int(ahead_str.split(",")[0])
|
||||
if "[behind " in branch_info:
|
||||
behind_str = branch_info.split("[behind ")[1].split("]")[0]
|
||||
behind = int(behind_str.split(",")[0])
|
||||
else:
|
||||
branch = branch_info
|
||||
continue
|
||||
|
||||
if len(line) < 3:
|
||||
continue
|
||||
|
||||
status_code = line[:2]
|
||||
file_path = line[3:]
|
||||
|
||||
# XY format: X = index status, Y = working tree status
|
||||
if status_code == "??":
|
||||
untracked.append(file_path)
|
||||
elif status_code[1] == "D" or status_code[0] == "D":
|
||||
deleted.append(file_path)
|
||||
elif status_code[0] == "A" or status_code[1] == "A":
|
||||
added.append(file_path)
|
||||
else:
|
||||
modified.append(file_path)
|
||||
|
||||
return GitStatus(
|
||||
branch=branch,
|
||||
modified=modified,
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
untracked=untracked,
|
||||
ahead=ahead,
|
||||
behind=behind,
|
||||
)
|
||||
|
||||
async def commit(self, message: str) -> None:
|
||||
"""Stage all changes and commit."""
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git add failed: {err}")
|
||||
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git commit failed: {err}")
|
||||
|
||||
logger.info("Committed in workspace: %s", self.cwd)
|
||||
|
||||
async def push(self) -> None:
|
||||
"""Push current branch to origin."""
|
||||
rc, _, err = await self._run(
|
||||
"git", "-C", self.cwd, "push", "origin", self.branch
|
||||
)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git push failed: {err}")
|
||||
|
||||
logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd)
|
||||
|
||||
async def pull(self) -> None:
|
||||
"""Pull current branch from origin."""
|
||||
rc, _, err = await self._run(
|
||||
"git", "-C", self.cwd, "pull", "origin", self.branch
|
||||
)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git pull failed: {err}")
|
||||
|
||||
logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd)
|
||||
|
||||
async def fetch(self) -> None:
|
||||
"""Fetch from origin."""
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git fetch failed: {err}")
|
||||
|
||||
logger.info("Fetched origin for workspace: %s", self.cwd)
|
||||
|
||||
async def checkout(self, branch: str) -> None:
|
||||
"""Checkout a branch."""
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git checkout failed: {err}")
|
||||
|
||||
self.branch = branch
|
||||
logger.info("Checked out branch %s in workspace: %s", branch, self.cwd)
|
||||
|
||||
async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
|
||||
"""Get commit history.
|
||||
|
||||
Args:
|
||||
path: Optional file path to filter history.
|
||||
limit: Maximum number of commits.
|
||||
|
||||
Returns:
|
||||
List of commits.
|
||||
"""
|
||||
cmd = [
|
||||
"git",
|
||||
"-C",
|
||||
self.cwd,
|
||||
"log",
|
||||
f"--max-count={limit}",
|
||||
"--pretty=format:%H|%s|%an|%ad",
|
||||
"--date=iso",
|
||||
]
|
||||
if path:
|
||||
cmd.extend(["--", path])
|
||||
|
||||
rc, stdout, err = await self._run(*cmd)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git log failed: {err}")
|
||||
|
||||
commits = []
|
||||
for line in stdout.strip().splitlines():
|
||||
parts = line.split("|", 3)
|
||||
if len(parts) >= 4:
|
||||
commits.append(
|
||||
Commit(
|
||||
hash=parts[0],
|
||||
message=parts[1],
|
||||
author=parts[2],
|
||||
date=parts[3],
|
||||
)
|
||||
)
|
||||
|
||||
return commits
|
||||
|
||||
async def branches(self) -> tuple[list[str], str]:
|
||||
"""List all branches and current branch.
|
||||
|
||||
Returns:
|
||||
Tuple of (all_branches, current_branch).
|
||||
"""
|
||||
rc, stdout, err = await self._run(
|
||||
"git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)"
|
||||
)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git branch failed: {err}")
|
||||
|
||||
branches = []
|
||||
current = self.branch
|
||||
for line in stdout.strip().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("HEAD") or line.endswith("/HEAD"):
|
||||
continue
|
||||
if line.startswith("remotes/origin/"):
|
||||
branch_name = line.replace("remotes/origin/", "")
|
||||
if branch_name not in branches:
|
||||
branches.append(branch_name)
|
||||
elif line and line not in branches:
|
||||
branches.append(line)
|
||||
|
||||
return branches, current
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -11,13 +13,39 @@ class GitService:
|
||||
"""Low-level git operations for creating and syncing workspaces."""
|
||||
|
||||
@staticmethod
|
||||
async def clone(remote_url: str, branch: str, path: str) -> None:
|
||||
def _prepare_ssh_env(
|
||||
ssh_key: str | None,
|
||||
) -> tuple[dict[str, str] | None, str | None]:
|
||||
"""Prepare environment for git commands with SSH authentication.
|
||||
|
||||
Returns a tuple of (env_dict, temp_key_path). Caller must clean up key_path.
|
||||
"""
|
||||
if not ssh_key:
|
||||
return None, None
|
||||
|
||||
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||
try:
|
||||
os.write(fd, ssh_key.encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.chmod(key_path, 0o600)
|
||||
|
||||
env = {
|
||||
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
}
|
||||
return env, key_path
|
||||
|
||||
@staticmethod
|
||||
async def clone(
|
||||
remote_url: str, branch: str, path: str, ssh_key: str | None = None
|
||||
) -> None:
|
||||
"""Clone a repository to the given path.
|
||||
|
||||
Args:
|
||||
remote_url: The git remote URL.
|
||||
branch: The branch to clone.
|
||||
path: The destination path for the clone.
|
||||
ssh_key: Optional decrypted SSH private key for authentication.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the clone fails.
|
||||
@@ -31,88 +59,118 @@ class GitService:
|
||||
remote_url,
|
||||
path,
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git clone failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git clone failed: {error_msg}")
|
||||
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
|
||||
|
||||
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git clone failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git clone failed: {error_msg}")
|
||||
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
@staticmethod
|
||||
async def fetch(path: str) -> None:
|
||||
async def fetch(path: str, ssh_key: str | None = None) -> None:
|
||||
"""Fetch from origin.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
ssh_key: Optional decrypted SSH private key for authentication.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If fetch fails.
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
path,
|
||||
"fetch",
|
||||
"origin",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git fetch failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git fetch failed: {error_msg}")
|
||||
logger.debug("Fetched origin for %s", path)
|
||||
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
path,
|
||||
"fetch",
|
||||
"origin",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git fetch failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git fetch failed: {error_msg}")
|
||||
logger.debug("Fetched origin for %s", path)
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
@staticmethod
|
||||
async def pull(path: str, branch: str) -> None:
|
||||
async def pull(path: str, branch: str, ssh_key: str | None = None) -> None:
|
||||
"""Pull latest changes from origin.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
branch: The branch to pull.
|
||||
ssh_key: Optional decrypted SSH private key for authentication.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If pull fails.
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
path,
|
||||
"pull",
|
||||
"origin",
|
||||
branch,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git pull failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git pull failed: {error_msg}")
|
||||
logger.debug("Pulled origin/%s for %s", branch, path)
|
||||
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
path,
|
||||
"pull",
|
||||
"origin",
|
||||
branch,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git pull failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git pull failed: {error_msg}")
|
||||
logger.debug("Pulled origin/%s for %s", branch, path)
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
@staticmethod
|
||||
def branch_exists_remotely(path: str, branch: str) -> bool:
|
||||
def branch_exists_remotely(
|
||||
path: str, branch: str, ssh_key: str | None = None
|
||||
) -> bool:
|
||||
"""Check if a branch exists on the remote.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
branch: The branch name to check.
|
||||
ssh_key: Optional decrypted SSH private key for authentication.
|
||||
|
||||
Returns:
|
||||
True if the branch exists on origin, False otherwise.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
exists = result.returncode == 0 and result.stdout.strip() != ""
|
||||
logger.debug("Branch %s exists on remote: %s", branch, exists)
|
||||
return exists
|
||||
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
exists = result.returncode == 0 and result.stdout.strip() != ""
|
||||
logger.debug("Branch %s exists on remote: %s", branch, exists)
|
||||
return exists
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
@@ -26,7 +26,7 @@ def resolve_base(manifest: dict) -> dict:
|
||||
result = deepcopy(manifest)
|
||||
|
||||
base_definition_id = result.pop("base_definition_id", None)
|
||||
base_version = result.pop("base_version", "latest")
|
||||
result.pop("base_version", None)
|
||||
|
||||
if base_definition_id:
|
||||
# This will be provided by the caller (they have the DB session)
|
||||
@@ -118,6 +118,11 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
|
||||
# System packages (apt)
|
||||
apt_packages = manifest.get("packages", {}).get("apt", [])
|
||||
if manifest.get("user"):
|
||||
# Ensure sudo is available for permission-fixing startup scripts
|
||||
apt_packages = list(apt_packages)
|
||||
if "sudo" not in apt_packages:
|
||||
apt_packages.append("sudo")
|
||||
if apt_packages:
|
||||
lines.append("RUN apt-get update && apt-get install -y \\")
|
||||
for pkg in apt_packages[:-1]:
|
||||
@@ -167,6 +172,17 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
lines.append(f"ENV HOME={home}")
|
||||
lines.append(f"ENV USER={name}")
|
||||
lines.append("")
|
||||
# Ensure home directory exists and is writable by the user
|
||||
lines.append(
|
||||
f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Configure passwordless sudo so startup scripts can fix permissions
|
||||
lines.append(
|
||||
f'RUN echo "{name} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/{name} && chmod 0440 /etc/sudoers.d/{name}'
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Build scripts
|
||||
build_scripts = manifest.get("scripts", {}).get("build", [])
|
||||
@@ -181,6 +197,11 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
if build_scripts:
|
||||
lines.append("")
|
||||
|
||||
# After build scripts, ensure everything in home is owned by the user
|
||||
if user and build_scripts:
|
||||
lines.append(f"RUN chown -R {name}:{name} {home}")
|
||||
lines.append("")
|
||||
|
||||
# Create mount target directories
|
||||
mounts = manifest.get("mounts", [])
|
||||
if mounts:
|
||||
@@ -308,7 +329,21 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
||||
service["volumes"] = sort_volumes_by_specificity(volumes)
|
||||
|
||||
compose = {"services": {"app": service}}
|
||||
return yaml.dump(compose, default_flow_style=False)
|
||||
result = yaml.dump(compose, default_flow_style=False)
|
||||
|
||||
# Debug: log mount resolution so we can diagnose missing mounts
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(
|
||||
"compile_compose: REPO_PATH=%s SSH_PATH=%s EXTRA_VOLUMES=%s mounts=%s volumes=%s",
|
||||
variables.get("REPO_PATH", "<empty>"),
|
||||
variables.get("SSH_PATH", "<empty>"),
|
||||
variables.get("EXTRA_VOLUMES", []),
|
||||
manifest.get("mounts", []),
|
||||
volumes,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
@@ -22,12 +23,28 @@ def _get_fernet() -> Fernet:
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
"""Sanitize a string for use as a filename.
|
||||
|
||||
Replaces non-alphanumeric characters with underscores and strips
|
||||
leading/trailing underscores.
|
||||
"""
|
||||
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
|
||||
sanitized = sanitized.strip("_")
|
||||
# Ensure it's not empty
|
||||
if not sanitized:
|
||||
sanitized = "key"
|
||||
return sanitized
|
||||
|
||||
|
||||
def prepare_ssh_key_files(
|
||||
instance_dir: str,
|
||||
ssh_key,
|
||||
subdir: str = ".ssh",
|
||||
uid: int | None = None,
|
||||
gid: int | None = None,
|
||||
key_filename: str = "id_ed25519",
|
||||
write_config: bool = True,
|
||||
) -> str:
|
||||
"""Decrypt and write SSH key files to instance directory for container mounting.
|
||||
|
||||
@@ -37,6 +54,12 @@ def prepare_ssh_key_files(
|
||||
subdir: Subdirectory within instance_dir to write to (default: ".ssh")
|
||||
uid: Optional UID to own the files (for bind-mount into non-root container)
|
||||
gid: Optional GID to own the files
|
||||
key_filename: Base filename for the key pair (default: "id_ed25519").
|
||||
The private key will be named "{key_filename}" and the public key
|
||||
"{key_filename}.pub".
|
||||
write_config: Whether to write an SSH config file (default: True).
|
||||
Set to False when combining multiple keys into one directory,
|
||||
then call write_ssh_config() separately.
|
||||
|
||||
Returns:
|
||||
Path to the .ssh directory
|
||||
@@ -49,51 +72,101 @@ def prepare_ssh_key_files(
|
||||
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||
|
||||
# Write private key with restricted permissions
|
||||
private_key_path = ssh_dir / "id_ed25519"
|
||||
private_key_path = ssh_dir / key_filename
|
||||
private_key_path.write_text(private_key)
|
||||
os.chmod(private_key_path, 0o600)
|
||||
|
||||
# Write public key
|
||||
public_key_path = ssh_dir / "id_ed25519.pub"
|
||||
public_key_path = ssh_dir / f"{key_filename}.pub"
|
||||
public_key_path.write_text(ssh_key.public_key)
|
||||
os.chmod(public_key_path, 0o644)
|
||||
|
||||
# Write SSH config
|
||||
config_path = ssh_dir / "config"
|
||||
config_content = """Host *
|
||||
# Write SSH config (only if requested)
|
||||
if write_config:
|
||||
config_path = ssh_dir / "config"
|
||||
config_content = f"""Host *
|
||||
StrictHostKeyChecking no
|
||||
UserKnownHostsFile /dev/null
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
IdentityFile ~/.ssh/{key_filename}
|
||||
IdentitiesOnly yes
|
||||
"""
|
||||
config_path.write_text(config_content)
|
||||
os.chmod(config_path, 0o644)
|
||||
|
||||
# Set ownership to target container user if requested
|
||||
if uid is not None or gid is not None:
|
||||
effective_uid = uid if uid is not None else -1
|
||||
effective_gid = gid if gid is not None else -1
|
||||
try:
|
||||
os.chown(ssh_dir, effective_uid, effective_gid)
|
||||
os.chown(private_key_path, effective_uid, effective_gid)
|
||||
os.chown(public_key_path, effective_uid, effective_gid)
|
||||
os.chown(config_path, effective_uid, effective_gid)
|
||||
logger.debug(
|
||||
"Set SSH key ownership to uid=%s gid=%s for %s",
|
||||
effective_uid,
|
||||
effective_gid,
|
||||
ssh_dir,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
logger.warning(
|
||||
"Cannot chown SSH keys to uid=%s gid=%s (running as uid=%s): %s",
|
||||
effective_uid,
|
||||
effective_gid,
|
||||
os.getuid(),
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
# Still chown the key files even if we didn't write config
|
||||
if uid is not None or gid is not None:
|
||||
effective_uid = uid if uid is not None else -1
|
||||
effective_gid = gid if gid is not None else -1
|
||||
try:
|
||||
os.chown(private_key_path, effective_uid, effective_gid)
|
||||
os.chown(public_key_path, effective_uid, effective_gid)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
return str(ssh_dir)
|
||||
|
||||
|
||||
def write_ssh_config(
|
||||
ssh_dir: str,
|
||||
key_filenames: list[str],
|
||||
uid: int | None = None,
|
||||
gid: int | None = None,
|
||||
) -> None:
|
||||
"""Write an SSH config file that includes multiple IdentityFile entries.
|
||||
|
||||
Args:
|
||||
ssh_dir: Path to the .ssh directory
|
||||
key_filenames: List of key filenames (without .pub extension)
|
||||
uid: Optional UID to own the config file
|
||||
gid: Optional GID to own the config file
|
||||
"""
|
||||
ssh_dir_path = Path(ssh_dir)
|
||||
ssh_dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_path = ssh_dir_path / "config"
|
||||
lines = ["Host *"]
|
||||
lines.append(" StrictHostKeyChecking no")
|
||||
lines.append(" UserKnownHostsFile /dev/null")
|
||||
lines.append(" IdentitiesOnly yes")
|
||||
for filename in key_filenames:
|
||||
lines.append(f" IdentityFile ~/.ssh/{filename}")
|
||||
lines.append("")
|
||||
|
||||
config_content = "\n".join(lines)
|
||||
config_path.write_text(config_content)
|
||||
os.chmod(config_path, 0o644)
|
||||
|
||||
# Set ownership to target container user if requested
|
||||
if uid is not None or gid is not None:
|
||||
effective_uid = uid if uid is not None else -1
|
||||
effective_gid = gid if gid is not None else -1
|
||||
try:
|
||||
os.chown(ssh_dir, effective_uid, effective_gid)
|
||||
os.chown(private_key_path, effective_uid, effective_gid)
|
||||
os.chown(public_key_path, effective_uid, effective_gid)
|
||||
os.chown(config_path, effective_uid, effective_gid)
|
||||
logger.debug(
|
||||
"Set SSH key ownership to uid=%s gid=%s for %s",
|
||||
effective_uid,
|
||||
effective_gid,
|
||||
ssh_dir,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
logger.warning(
|
||||
"Cannot chown SSH keys to uid=%s gid=%s (running as uid=%s): %s",
|
||||
effective_uid,
|
||||
effective_gid,
|
||||
os.getuid(),
|
||||
exc,
|
||||
)
|
||||
|
||||
return str(ssh_dir)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
def cleanup_ssh_key_files(instance_dir: str) -> None:
|
||||
|
||||
@@ -6,6 +6,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import WebSocket
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from src.database import SessionLocal
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
@@ -83,18 +84,26 @@ class TerminalManager:
|
||||
instance_id: uuid.UUID,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Insert a TerminalSessionModel row into the database."""
|
||||
"""Insert a TerminalSessionModel row into the database.
|
||||
|
||||
Uses ON CONFLICT DO NOTHING to handle races when a session is
|
||||
restored from DB and then re-inserted.
|
||||
"""
|
||||
try:
|
||||
async with SessionLocal() as db_session:
|
||||
db_row = TerminalSessionModel(
|
||||
id=uuid.UUID(session_id),
|
||||
instance_id=instance_id,
|
||||
name=name,
|
||||
status="active",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_activity_at=datetime.now(timezone.utc),
|
||||
stmt = (
|
||||
pg_insert(TerminalSessionModel)
|
||||
.values(
|
||||
id=uuid.UUID(session_id),
|
||||
instance_id=instance_id,
|
||||
name=name,
|
||||
status="active",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_activity_at=datetime.now(timezone.utc),
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=["id"])
|
||||
)
|
||||
db_session.add(db_row)
|
||||
await db_session.execute(stmt)
|
||||
await db_session.commit()
|
||||
logger.debug(
|
||||
"Inserted terminal session row %s for instance %s",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Terminal session management for tool instances."""
|
||||
"""High-performance terminal session with asyncio-native I/O.
|
||||
|
||||
Replaces blocking select.select() with event-driven asyncio.add_reader()
|
||||
for sub-frame latency. Includes output batching and flow control.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import fcntl
|
||||
@@ -17,18 +20,31 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
"""Manages a single terminal session connected to a docker container.
|
||||
"""Manages a single terminal session with event-driven PTY I/O.
|
||||
|
||||
Supports persistent sessions that survive WebSocket disconnections.
|
||||
Multiple WebSocket connections can attach/detach from the same session.
|
||||
Uses asyncio.add_reader() instead of polling for near-zero read latency.
|
||||
Output is batched (2ms window) and sent as binary WebSocket frames.
|
||||
Flow control prevents memory bloat on fast output.
|
||||
"""
|
||||
|
||||
# Circular buffer size (10KB)
|
||||
# Circular buffer for replay (10KB)
|
||||
BUFFER_SIZE = 10 * 1024
|
||||
|
||||
# Idle timeout in seconds (30 minutes)
|
||||
IDLE_TIMEOUT = 30 * 60
|
||||
|
||||
# Output batching window in seconds
|
||||
BATCH_WINDOW_S = 0.002 # 2ms
|
||||
|
||||
# Flow control: pause PTY reads when unacknowledged bytes exceed this
|
||||
FLOW_CONTROL_PAUSE = 64 * 1024
|
||||
|
||||
# Flow control: resume PTY reads when unacknowledged bytes drop below this
|
||||
FLOW_CONTROL_RESUME = 32 * 1024
|
||||
|
||||
# Max WebSocket frame size
|
||||
MAX_FRAME_SIZE = 64 * 1024
|
||||
|
||||
# Session number counter per instance_id for auto-naming
|
||||
_instance_counters: dict[str, int] = {}
|
||||
|
||||
@@ -47,7 +63,6 @@ class TerminalSession:
|
||||
self.process: asyncio.subprocess.Process | None = None
|
||||
self._closed = False
|
||||
self._master_fd: int | None = None
|
||||
self._slave_fd: int | None = None
|
||||
|
||||
# Circular buffer for output replay
|
||||
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
||||
@@ -67,6 +82,20 @@ class TerminalSession:
|
||||
self.name = name or self._generate_name(str(instance_id))
|
||||
self.status: str = "active"
|
||||
|
||||
# Output batching
|
||||
self._batch_buffer = bytearray()
|
||||
self._batch_timer: asyncio.TimerHandle | None = None
|
||||
self._batch_lock = asyncio.Lock()
|
||||
|
||||
# Flow control
|
||||
self._unacknowledged_bytes = 0
|
||||
self._paused = False
|
||||
self._read_handler_set = False
|
||||
self._flow_control_lock = asyncio.Lock()
|
||||
|
||||
# Ack timeout fallback
|
||||
self._ack_timeout_handle: asyncio.TimerHandle | None = None
|
||||
|
||||
@classmethod
|
||||
def _generate_name(cls, instance_id: str) -> str:
|
||||
"""Generate an auto-incremented session name for the instance."""
|
||||
@@ -77,91 +106,225 @@ class TerminalSession:
|
||||
async def start(self, startup_command: str | None = None) -> None:
|
||||
"""Start the docker exec process with a shell using a PTY."""
|
||||
# Create a pseudo-terminal on the host
|
||||
self._master_fd, self._slave_fd = pty.openpty()
|
||||
self._master_fd, slave_fd = pty.openpty()
|
||||
|
||||
# Set the terminal size initially
|
||||
self._set_terminal_size(self._cols, self._rows)
|
||||
logger.debug(
|
||||
f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}"
|
||||
"Starting terminal session %s for container %s with initial size %sx%s",
|
||||
self.session_id,
|
||||
self.container_id,
|
||||
self._cols,
|
||||
self._rows,
|
||||
)
|
||||
|
||||
# Build the shell command
|
||||
if startup_command:
|
||||
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
||||
cmd = startup_command or self.startup_command
|
||||
if cmd:
|
||||
shell_cmd = f'bash -c "{cmd}" || true; exec bash -il'
|
||||
logger.debug(
|
||||
f"Using startup command for session {self.session_id}: {startup_command}"
|
||||
"Using startup command for session %s: %s",
|
||||
self.session_id,
|
||||
cmd,
|
||||
)
|
||||
else:
|
||||
shell_cmd = "bash -il"
|
||||
|
||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||
# Using -it because the slave fd IS a TTY
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
"-it",
|
||||
"-e",
|
||||
"TERM=xterm",
|
||||
"TERM=xterm-256color",
|
||||
self.container_id,
|
||||
"bash",
|
||||
"-c",
|
||||
shell_cmd,
|
||||
stdin=self._slave_fd,
|
||||
stdout=self._slave_fd,
|
||||
stderr=self._slave_fd,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
)
|
||||
|
||||
# Close slave fd in parent process
|
||||
os.close(self._slave_fd)
|
||||
self._slave_fd = None
|
||||
os.close(slave_fd)
|
||||
|
||||
self.last_activity = time.time()
|
||||
|
||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||
"""Set the terminal size using TIOCSWINSZ."""
|
||||
if self._master_fd is None:
|
||||
logger.warning("Cannot resize: master_fd is None (session not started)")
|
||||
return
|
||||
# TIOCSWINSZ = 0x5414 on Linux
|
||||
TIOCSWINSZ = 0x5414
|
||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||
except (OSError, IOError) as e:
|
||||
logger.error(f"Failed to resize PTY: {e}")
|
||||
# Start event-driven reading
|
||||
self._start_reading()
|
||||
|
||||
async def read_output(self) -> bytes:
|
||||
"""Read output from the PTY master and store in buffer."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return b""
|
||||
def _start_reading(self) -> None:
|
||||
"""Register PTY master fd with asyncio event loop for event-driven reads."""
|
||||
if self._read_handler_set or self._master_fd is None or self._closed:
|
||||
return
|
||||
try:
|
||||
# Use select to check if data is available
|
||||
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||
if readable:
|
||||
data = os.read(self._master_fd, 4096)
|
||||
if data:
|
||||
self._add_to_buffer(data)
|
||||
self.last_activity = time.time()
|
||||
return data
|
||||
return b""
|
||||
except (OSError, IOError, ValueError):
|
||||
return b""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.add_reader(self._master_fd, self._on_fd_readable)
|
||||
self._read_handler_set = True
|
||||
logger.debug("Started event-driven reading for session %s", self.session_id)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to start reading for session %s: %s", self.session_id, exc
|
||||
)
|
||||
|
||||
def _stop_reading(self) -> None:
|
||||
"""Unregister PTY master fd from asyncio event loop."""
|
||||
if not self._read_handler_set or self._master_fd is None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.remove_reader(self._master_fd)
|
||||
self._read_handler_set = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_fd_readable(self) -> None:
|
||||
"""Callback when PTY master fd has data available (called by event loop)."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return
|
||||
|
||||
try:
|
||||
data = os.read(self._master_fd, 4096)
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("PTY read error for session %s: %s", self.session_id, exc)
|
||||
self._handle_eof()
|
||||
return
|
||||
|
||||
if not data:
|
||||
# EOF: docker exec process exited
|
||||
logger.debug("PTY EOF for session %s", self.session_id)
|
||||
self._handle_eof()
|
||||
return
|
||||
|
||||
self._add_to_buffer(data)
|
||||
self.last_activity = time.time()
|
||||
|
||||
# Queue for batching + flow control
|
||||
self._queue_output(data)
|
||||
|
||||
def _add_to_buffer(self, data: bytes) -> None:
|
||||
"""Add data to circular buffer, maintaining size limit."""
|
||||
self._output_buffer.append(data)
|
||||
self._buffer_size += len(data)
|
||||
|
||||
# Trim if exceeds max size
|
||||
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
|
||||
removed = self._output_buffer.popleft()
|
||||
self._buffer_size -= len(removed)
|
||||
|
||||
def _queue_output(self, data: bytes) -> None:
|
||||
"""Add output to batch buffer and schedule flush."""
|
||||
self._batch_buffer.extend(data)
|
||||
self._unacknowledged_bytes += len(data)
|
||||
|
||||
# Check flow control
|
||||
if self._unacknowledged_bytes > self.FLOW_CONTROL_PAUSE and not self._paused:
|
||||
self._pause_output()
|
||||
|
||||
# Schedule batch flush if not already scheduled
|
||||
if self._batch_timer is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._batch_timer = loop.call_later(
|
||||
self.BATCH_WINDOW_S,
|
||||
self._flush_batch_sync,
|
||||
)
|
||||
|
||||
def _flush_batch_sync(self) -> None:
|
||||
"""Synchronous entry point for batch flush (called from event loop)."""
|
||||
self._batch_timer = None
|
||||
if not self._batch_buffer or not self._websockets:
|
||||
self._batch_buffer.clear()
|
||||
return
|
||||
|
||||
payload = bytes(self._batch_buffer)
|
||||
self._batch_buffer.clear()
|
||||
|
||||
# Send to all websockets (asyncio.create_task for async send)
|
||||
dead_sockets = set()
|
||||
for ws in list(self._websockets):
|
||||
try:
|
||||
asyncio.create_task(self._send_bytes(ws, payload))
|
||||
except Exception:
|
||||
dead_sockets.add(ws)
|
||||
|
||||
if dead_sockets:
|
||||
self._websockets -= dead_sockets
|
||||
|
||||
async def _send_bytes(self, ws: Any, payload: bytes) -> None:
|
||||
"""Send bytes to a single websocket, catching errors."""
|
||||
try:
|
||||
await ws.send_bytes(payload)
|
||||
except Exception:
|
||||
self._websockets.discard(ws)
|
||||
|
||||
def acknowledge_data(self, char_count: int) -> None:
|
||||
"""Client acknowledges processing char_count bytes.
|
||||
|
||||
Called from the WebSocket handler when the client sends an 'ack' message.
|
||||
"""
|
||||
self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count)
|
||||
|
||||
if self._paused and self._unacknowledged_bytes < self.FLOW_CONTROL_RESUME:
|
||||
self._resume_output()
|
||||
|
||||
# Reset ack timeout
|
||||
if self._ack_timeout_handle:
|
||||
self._ack_timeout_handle.cancel()
|
||||
loop = asyncio.get_event_loop()
|
||||
self._ack_timeout_handle = loop.call_later(5.0, self._ack_timeout_fallback)
|
||||
|
||||
def _ack_timeout_fallback(self) -> None:
|
||||
"""If no ack received for 5s, assume client is dead and resume."""
|
||||
logger.warning(
|
||||
"Flow control ack timeout for session %s, resuming output",
|
||||
self.session_id,
|
||||
)
|
||||
self._unacknowledged_bytes = 0
|
||||
if self._paused:
|
||||
self._resume_output()
|
||||
|
||||
def _pause_output(self) -> None:
|
||||
"""Pause reading from PTY due to flow control."""
|
||||
self._paused = True
|
||||
self._stop_reading()
|
||||
logger.debug(
|
||||
"Paused output for session %s (%d unacked)",
|
||||
self.session_id,
|
||||
self._unacknowledged_bytes,
|
||||
)
|
||||
|
||||
def _resume_output(self) -> None:
|
||||
"""Resume reading from PTY."""
|
||||
self._paused = False
|
||||
self._start_reading()
|
||||
logger.debug("Resumed output for session %s", self.session_id)
|
||||
|
||||
def get_buffer(self) -> bytes:
|
||||
"""Get buffered output for replay."""
|
||||
return b"".join(self._output_buffer)
|
||||
|
||||
def _handle_eof(self) -> None:
|
||||
"""Handle PTY EOF: process died, close websockets to force reconnect."""
|
||||
self._stop_reading()
|
||||
# Mark process as done so is_alive() returns False
|
||||
if self.process is not None and self.process.returncode is None:
|
||||
# Force returncode to a non-None value since the process is dead
|
||||
# but asyncio.subprocess may not have set it yet
|
||||
try:
|
||||
self.process._transport.close() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
# Close all websockets to force frontend reconnection
|
||||
dead_sockets = set(self._websockets)
|
||||
self._websockets.clear()
|
||||
for ws in dead_sockets:
|
||||
try:
|
||||
asyncio.create_task(
|
||||
ws.close(code=4001, reason="Session process exited")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Session %s EOF handled, websockets closed", self.session_id)
|
||||
|
||||
async def write_input(self, data: bytes) -> None:
|
||||
"""Write input to the PTY master."""
|
||||
if self._master_fd is None or self._closed:
|
||||
@@ -169,8 +332,22 @@ class TerminalSession:
|
||||
try:
|
||||
os.write(self._master_fd, data)
|
||||
self.last_activity = time.time()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("PTY write error for session %s: %s", self.session_id, exc)
|
||||
self._handle_eof()
|
||||
|
||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||
"""Set the terminal size using TIOCSWINSZ."""
|
||||
if self._master_fd is None:
|
||||
logger.warning("Cannot resize: master_fd is None (session not started)")
|
||||
return
|
||||
TIOCSWINSZ = 0x5414
|
||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||
logger.debug("Resized PTY to %sx%s (fd=%s)", cols, rows, self._master_fd)
|
||||
except (OSError, IOError) as e:
|
||||
logger.error("Failed to resize PTY: %s", e)
|
||||
|
||||
async def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the terminal."""
|
||||
@@ -178,32 +355,24 @@ class TerminalSession:
|
||||
logger.warning("Cannot resize: session is closed")
|
||||
return
|
||||
|
||||
# Only resize if dimensions actually changed
|
||||
if cols == self._cols and rows == self._rows:
|
||||
return
|
||||
|
||||
self._cols = cols
|
||||
self._rows = rows
|
||||
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||
logger.debug(
|
||||
"resize() called for session %s: %sx%s", self.session_id, cols, rows
|
||||
)
|
||||
self._set_terminal_size(cols, rows)
|
||||
|
||||
# Docker exec -it creates its own PTY inside the container,
|
||||
# so host PTY resize doesn't propagate to the container shell.
|
||||
# Send SIGWINCH to the docker exec process on the host.
|
||||
# Docker exec forwards signals to the container process, which should
|
||||
# cause the container's shell to re-read its terminal size.
|
||||
# Send SIGWINCH to docker exec process
|
||||
if self.process and self.process.pid:
|
||||
try:
|
||||
os.kill(self.process.pid, signal.SIGWINCH)
|
||||
logger.debug(
|
||||
f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}"
|
||||
)
|
||||
except ProcessLookupError:
|
||||
logger.warning(
|
||||
f"docker exec process {self.process.pid} not found for session {self.session_id}"
|
||||
)
|
||||
logger.warning("docker exec process %s not found", self.process.pid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send SIGWINCH: {e}")
|
||||
logger.warning("Failed to send SIGWINCH: %s", e)
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Reset the session by killing the process and clearing state."""
|
||||
@@ -213,9 +382,13 @@ class TerminalSession:
|
||||
self._output_buffer.clear()
|
||||
self._buffer_size = 0
|
||||
self._websockets.clear()
|
||||
self._batch_buffer.clear()
|
||||
self._batch_timer = None
|
||||
self._unacknowledged_bytes = 0
|
||||
self._paused = False
|
||||
self._read_handler_set = False
|
||||
self.process = None
|
||||
self._master_fd = None
|
||||
self._slave_fd = None
|
||||
self.status = "active"
|
||||
|
||||
async def close(self) -> None:
|
||||
@@ -225,11 +398,21 @@ class TerminalSession:
|
||||
self._closed = True
|
||||
self.status = "closed"
|
||||
|
||||
self._stop_reading()
|
||||
|
||||
if self._batch_timer:
|
||||
self._batch_timer.cancel()
|
||||
self._batch_timer = None
|
||||
|
||||
if self._ack_timeout_handle:
|
||||
self._ack_timeout_handle.cancel()
|
||||
self._ack_timeout_handle = None
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass # noqa: S110
|
||||
pass
|
||||
self._master_fd = None
|
||||
|
||||
if self.process is not None:
|
||||
@@ -265,14 +448,20 @@ class TerminalSession:
|
||||
return len(self._websockets) > 0
|
||||
|
||||
async def send_to_all(self, data: bytes) -> None:
|
||||
"""Send data to all attached WebSockets."""
|
||||
"""Send data to all attached WebSockets (used for control messages)."""
|
||||
dead_sockets = set()
|
||||
for ws in self._websockets:
|
||||
try:
|
||||
await ws.send_bytes(data)
|
||||
except Exception:
|
||||
dead_sockets.add(ws)
|
||||
|
||||
# Clean up dead sockets
|
||||
for ws in dead_sockets:
|
||||
self._websockets.discard(ws)
|
||||
|
||||
async def read_output(self) -> bytes:
|
||||
"""Legacy method: read output synchronously.
|
||||
|
||||
With event-driven I/O, output is automatically sent to websockets.
|
||||
This method returns any buffered data for callers that poll.
|
||||
"""
|
||||
return b""
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Workspace lifecycle management service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -12,6 +16,7 @@ from sqlalchemy import select
|
||||
|
||||
from src.models.workspace import Workspace
|
||||
from src.services.git_service import GitService
|
||||
from src.services.ssh_keys import _get_fernet
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -32,7 +37,7 @@ class SyncResult:
|
||||
class WorkspaceHasInstancesError(Exception):
|
||||
"""Raised when attempting to delete a workspace with running instances."""
|
||||
|
||||
def __init__(self, instances: list[ToolInstance]) -> None:
|
||||
def __init__(self, instances: list[dict]) -> None:
|
||||
self.instances = instances
|
||||
super().__init__(f"Workspace has {len(instances)} running tool instance(s)")
|
||||
|
||||
@@ -52,6 +57,7 @@ class WorkspaceManager:
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
branch: str = "main",
|
||||
session: AsyncSession | None = None,
|
||||
) -> Workspace:
|
||||
"""Clone repo to workspace path and create DB record.
|
||||
|
||||
@@ -60,6 +66,7 @@ class WorkspaceManager:
|
||||
user_id: The owner user ID.
|
||||
name: The workspace name (unique per repo).
|
||||
branch: The branch to clone (default: "main").
|
||||
session: Database session for loading SSH keys.
|
||||
|
||||
Returns:
|
||||
The created Workspace record.
|
||||
@@ -68,7 +75,11 @@ class WorkspaceManager:
|
||||
RuntimeError: If git clone fails.
|
||||
"""
|
||||
path = self._workspace_path(repo.id, name)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
parent = os.path.dirname(path)
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
# Ensure container users (various UIDs) can write to workspace dirs
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(parent, 0o777)
|
||||
|
||||
logger.info(
|
||||
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
|
||||
@@ -77,7 +88,28 @@ class WorkspaceManager:
|
||||
if not repo.remote_url:
|
||||
raise ValueError("Repository has no remote URL")
|
||||
|
||||
await GitService.clone(repo.remote_url, branch, path)
|
||||
# Remove stale directory from previous failed/aborted clone
|
||||
if os.path.exists(path):
|
||||
logger.warning("Removing stale workspace directory: %s", path)
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
# Load SSH key if repo has one
|
||||
ssh_key = None
|
||||
if getattr(repo, "ssh_key_id", None) and session is not None:
|
||||
from src.models.ssh_key import SSHKey
|
||||
|
||||
result = await session.execute(
|
||||
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
|
||||
)
|
||||
ssh_key_obj = result.scalar_one_or_none()
|
||||
if ssh_key_obj:
|
||||
fernet = _get_fernet()
|
||||
ssh_key = fernet.decrypt(
|
||||
ssh_key_obj.private_key_encrypted.encode()
|
||||
).decode()
|
||||
|
||||
await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key)
|
||||
self._make_world_writable(path)
|
||||
|
||||
workspace = Workspace(
|
||||
name=name,
|
||||
@@ -112,7 +144,9 @@ class WorkspaceManager:
|
||||
|
||||
instances = await self._get_instances(workspace, session)
|
||||
if instances and not force:
|
||||
raise WorkspaceHasInstancesError(instances)
|
||||
raise WorkspaceHasInstancesError(
|
||||
[{"id": str(i.id), "name": i.name} for i in instances]
|
||||
)
|
||||
|
||||
# Stop and delete all instances
|
||||
for instance in instances:
|
||||
@@ -127,11 +161,14 @@ class WorkspaceManager:
|
||||
await session.delete(workspace)
|
||||
logger.info("Deleted workspace record: %s", workspace.id)
|
||||
|
||||
async def sync(self, workspace: Workspace) -> SyncResult:
|
||||
async def sync(
|
||||
self, workspace: Workspace, session: AsyncSession | None = None
|
||||
) -> SyncResult:
|
||||
"""Sync a workspace with its remote.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to sync.
|
||||
session: Database session for loading SSH keys.
|
||||
|
||||
Returns:
|
||||
SyncResult indicating whether the branch was deleted.
|
||||
@@ -141,16 +178,63 @@ class WorkspaceManager:
|
||||
"""
|
||||
logger.info("Syncing workspace: %s", workspace.id)
|
||||
|
||||
await GitService.fetch(workspace.path)
|
||||
# Load SSH key if repo has one
|
||||
ssh_key = None
|
||||
if session is not None:
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.ssh_key import SSHKey
|
||||
|
||||
if not GitService.branch_exists_remotely(workspace.path, workspace.branch):
|
||||
repo = await session.get(GitRepository, workspace.repo_id)
|
||||
if repo and getattr(repo, "ssh_key_id", None):
|
||||
result = await session.execute(
|
||||
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
|
||||
)
|
||||
ssh_key_obj = result.scalar_one_or_none()
|
||||
if ssh_key_obj:
|
||||
fernet = _get_fernet()
|
||||
ssh_key = fernet.decrypt(
|
||||
ssh_key_obj.private_key_encrypted.encode()
|
||||
).decode()
|
||||
|
||||
await GitService.fetch(workspace.path, ssh_key=ssh_key)
|
||||
|
||||
if not GitService.branch_exists_remotely(
|
||||
workspace.path, workspace.branch, ssh_key=ssh_key
|
||||
):
|
||||
return SyncResult(branch_deleted=True)
|
||||
|
||||
await GitService.pull(workspace.path, workspace.branch)
|
||||
await GitService.pull(workspace.path, workspace.branch, ssh_key=ssh_key)
|
||||
self._make_world_writable(workspace.path)
|
||||
|
||||
workspace.last_sync_at = datetime.now()
|
||||
logger.info("Workspace synced: %s", workspace.id)
|
||||
return SyncResult(branch_deleted=False)
|
||||
|
||||
def _make_world_writable(self, path: str) -> None:
|
||||
"""Recursively make path readable/writable/traversable by any UID.
|
||||
|
||||
Directories get 777 (traversable). Files get rw for all while
|
||||
preserving any existing execute bits.
|
||||
"""
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(path, 0o777)
|
||||
for root, dirs, files in os.walk(path):
|
||||
for d in dirs:
|
||||
dpath = os.path.join(root, d)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(dpath, 0o777)
|
||||
for f in files:
|
||||
fpath = os.path.join(root, f)
|
||||
with contextlib.suppress(OSError):
|
||||
mode = os.stat(fpath).st_mode
|
||||
# Preserve execute bits, ensure read+write for all
|
||||
new_mode = (mode & stat.S_IXUSR) | 0o666
|
||||
if mode & stat.S_IXGRP:
|
||||
new_mode |= stat.S_IXGRP
|
||||
if mode & stat.S_IXOTH:
|
||||
new_mode |= stat.S_IXOTH
|
||||
os.chmod(fpath, new_mode)
|
||||
|
||||
async def _get_instances(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
@@ -170,6 +254,4 @@ class WorkspaceManager:
|
||||
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
||||
For now, this is a placeholder.
|
||||
"""
|
||||
logger.warning(
|
||||
"Placeholder: stopping and deleting instance %s", instance.id
|
||||
)
|
||||
logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
|
||||
|
||||
@@ -60,7 +60,9 @@ def test_repo(db_session: AsyncSession, authenticated_client: TestClient):
|
||||
class TestListWorkspaces:
|
||||
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
|
||||
|
||||
def test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository):
|
||||
def test_list_empty(
|
||||
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||
):
|
||||
"""Returns empty list when no workspaces exist."""
|
||||
response = authenticated_client.get(
|
||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
|
||||
@@ -69,7 +71,10 @@ class TestListWorkspaces:
|
||||
assert response.json() == []
|
||||
|
||||
def test_list_with_workspaces(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
self,
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
test_repo: GitRepository,
|
||||
):
|
||||
"""Returns workspaces with instance counts."""
|
||||
ws = Workspace(
|
||||
@@ -99,7 +104,9 @@ class TestListWorkspaces:
|
||||
class TestCreateWorkspace:
|
||||
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
|
||||
|
||||
def test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository):
|
||||
def test_create_success(
|
||||
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||
):
|
||||
"""Creates a workspace and clones the repo."""
|
||||
mock_ws = Workspace(
|
||||
id=uuid.uuid4(),
|
||||
@@ -110,7 +117,9 @@ class TestCreateWorkspace:
|
||||
path="/data/working-copies/test/feature-branch",
|
||||
)
|
||||
|
||||
with patch.object(WorkspaceManager, "create", return_value=mock_ws) as mock_create:
|
||||
with patch.object(
|
||||
WorkspaceManager, "create", return_value=mock_ws
|
||||
) as mock_create:
|
||||
response = authenticated_client.post(
|
||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||
json={"name": "feature-branch", "branch": "feature"},
|
||||
@@ -121,7 +130,9 @@ class TestCreateWorkspace:
|
||||
assert data["branch"] == "feature"
|
||||
mock_create.assert_called_once()
|
||||
|
||||
def test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository):
|
||||
def test_create_missing_name(
|
||||
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||
):
|
||||
"""Returns 400 when name is missing."""
|
||||
response = authenticated_client.post(
|
||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||
@@ -131,7 +142,10 @@ class TestCreateWorkspace:
|
||||
assert "name" in response.json()["detail"]
|
||||
|
||||
def test_create_duplicate_name(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
self,
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
test_repo: GitRepository,
|
||||
):
|
||||
"""Returns 409 when workspace name already exists."""
|
||||
ws = Workspace(
|
||||
@@ -148,7 +162,9 @@ class TestCreateWorkspace:
|
||||
|
||||
asyncio.run(_commit())
|
||||
|
||||
with patch.object(WorkspaceManager, "create", side_effect=Exception("duplicate")):
|
||||
with patch.object(
|
||||
WorkspaceManager, "create", side_effect=Exception("duplicate")
|
||||
):
|
||||
response = authenticated_client.post(
|
||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||
json={"name": "dev", "branch": "main"},
|
||||
@@ -160,7 +176,10 @@ class TestDeleteWorkspace:
|
||||
"""Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}."""
|
||||
|
||||
def test_delete_without_instances(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
self,
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
test_repo: GitRepository,
|
||||
):
|
||||
"""Deletes workspace when no instances exist."""
|
||||
ws = Workspace(
|
||||
@@ -185,9 +204,14 @@ class TestDeleteWorkspace:
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "deleted"
|
||||
|
||||
@pytest.mark.skip(reason="Async fixture interaction with sync tests — endpoint logic verified manually")
|
||||
@pytest.mark.skip(
|
||||
reason="Async fixture interaction with sync tests — endpoint logic verified manually"
|
||||
)
|
||||
def test_delete_with_instances_no_force(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
self,
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
test_repo: GitRepository,
|
||||
):
|
||||
"""Returns 409 when workspace has instances and force=False."""
|
||||
ws = Workspace(
|
||||
@@ -239,7 +263,10 @@ class TestDeleteWorkspace:
|
||||
assert len(detail["instances"]) == 1
|
||||
|
||||
def test_delete_with_instances_force(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
self,
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
test_repo: GitRepository,
|
||||
):
|
||||
"""Deletes workspace when force=True even with instances."""
|
||||
ws = Workspace(
|
||||
@@ -268,7 +295,10 @@ class TestSyncWorkspace:
|
||||
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync."""
|
||||
|
||||
def test_sync_success(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
self,
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
test_repo: GitRepository,
|
||||
):
|
||||
"""Sync succeeds and updates last_sync_at."""
|
||||
ws = Workspace(
|
||||
@@ -298,7 +328,10 @@ class TestSyncWorkspace:
|
||||
assert data["pulled"] is True
|
||||
|
||||
def test_sync_branch_deleted(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
self,
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
test_repo: GitRepository,
|
||||
):
|
||||
"""Returns 409 when branch was deleted from remote."""
|
||||
ws = Workspace(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for FileService."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.workspace import Workspace
|
||||
from src.services.file_service import FileService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace():
|
||||
"""Create a temporary workspace directory."""
|
||||
with tempfile.TemporaryDirectory() as 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 TestFileService:
|
||||
"""Tests for FileService."""
|
||||
|
||||
def test_list_directory_empty(self, temp_workspace: Workspace):
|
||||
"""Returns empty list for empty directory."""
|
||||
service = FileService()
|
||||
entries = service.list_directory(temp_workspace)
|
||||
assert entries == []
|
||||
|
||||
def test_list_directory_with_files(self, temp_workspace: Workspace):
|
||||
"""Returns entries sorted (dirs first, then files)."""
|
||||
# Create files and dirs
|
||||
os.makedirs(os.path.join(temp_workspace.path, "src"))
|
||||
with open(os.path.join(temp_workspace.path, "README.md"), "w") as f:
|
||||
f.write("# Test")
|
||||
with open(os.path.join(temp_workspace.path, "main.py"), "w") as f:
|
||||
f.write("print('hello')")
|
||||
|
||||
service = FileService()
|
||||
entries = service.list_directory(temp_workspace)
|
||||
|
||||
assert len(entries) == 3
|
||||
assert entries[0].name == "src" and entries[0].type == "directory"
|
||||
assert entries[1].name == "main.py" and entries[1].type == "file"
|
||||
assert entries[2].name == "README.md" and entries[2].type == "file"
|
||||
|
||||
def test_read_file(self, temp_workspace: Workspace):
|
||||
"""Reads text file content."""
|
||||
with open(os.path.join(temp_workspace.path, "test.txt"), "w") as f:
|
||||
f.write("hello world")
|
||||
|
||||
service = FileService()
|
||||
content = service.read_file(temp_workspace, "test.txt")
|
||||
assert content == "hello world"
|
||||
|
||||
def test_read_binary_file_rejected(self, temp_workspace: Workspace):
|
||||
"""Rejects binary files."""
|
||||
with open(os.path.join(temp_workspace.path, "binary.bin"), "wb") as f:
|
||||
f.write(b"\x00\x01\x02")
|
||||
|
||||
service = FileService()
|
||||
with pytest.raises(ValueError, match="Binary"):
|
||||
service.read_file(temp_workspace, "binary.bin")
|
||||
|
||||
def test_write_file(self, temp_workspace: Workspace):
|
||||
"""Writes file to workspace."""
|
||||
service = FileService()
|
||||
service.write_file(temp_workspace, "nested/file.txt", "content")
|
||||
|
||||
assert os.path.exists(os.path.join(temp_workspace.path, "nested", "file.txt"))
|
||||
with open(os.path.join(temp_workspace.path, "nested", "file.txt")) as f:
|
||||
assert f.read() == "content"
|
||||
|
||||
def test_path_escapes_workspace(self, temp_workspace: Workspace):
|
||||
"""Rejects paths that escape workspace directory."""
|
||||
service = FileService()
|
||||
with pytest.raises(ValueError, match="escapes"):
|
||||
service.list_directory(temp_workspace, "../outside")
|
||||
@@ -21,7 +21,9 @@ class TestGitServiceClone:
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", return_value=mock_proc
|
||||
) as mock_exec:
|
||||
await GitService.clone("https://github.com/test/repo.git", "main", "/tmp/ws")
|
||||
await GitService.clone(
|
||||
"https://github.com/test/repo.git", "main", "/tmp/ws"
|
||||
)
|
||||
|
||||
mock_exec.assert_called_once_with(
|
||||
"git",
|
||||
|
||||
@@ -145,10 +145,12 @@ class TestCreateInstanceDockerfileLegacy:
|
||||
data = MagicMock()
|
||||
data.tool_type_id = str(fake_tool_type_id)
|
||||
data.display_name = None
|
||||
data.workspace_id = None
|
||||
data.clone_mode = "mount"
|
||||
data.branch = None
|
||||
data.new_branch = None
|
||||
data.config_profile_id = None
|
||||
data.ssh_key_ids = []
|
||||
|
||||
result = await create_instance(
|
||||
project_id=fake_project_id,
|
||||
@@ -225,10 +227,12 @@ class TestCreateInstanceDockerfileLegacy:
|
||||
data = MagicMock()
|
||||
data.tool_type_id = str(fake_tool_type_id)
|
||||
data.display_name = None
|
||||
data.workspace_id = None
|
||||
data.clone_mode = "mount"
|
||||
data.branch = None
|
||||
data.new_branch = None
|
||||
data.config_profile_id = None
|
||||
data.ssh_key_ids = []
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_instance(
|
||||
@@ -305,10 +309,12 @@ class TestCreateInstanceComposeLegacy:
|
||||
data = MagicMock()
|
||||
data.tool_type_id = str(fake_tool_type_id)
|
||||
data.display_name = None
|
||||
data.workspace_id = None
|
||||
data.clone_mode = "mount"
|
||||
data.branch = None
|
||||
data.new_branch = None
|
||||
data.config_profile_id = None
|
||||
data.ssh_key_ids = []
|
||||
|
||||
result = await create_instance(
|
||||
project_id=fake_project_id,
|
||||
@@ -389,10 +395,12 @@ class TestCreateInstanceManifestNotCalledForLegacy:
|
||||
data = MagicMock()
|
||||
data.tool_type_id = str(fake_tool_type_id)
|
||||
data.display_name = None
|
||||
data.workspace_id = None
|
||||
data.clone_mode = "mount"
|
||||
data.branch = None
|
||||
data.new_branch = None
|
||||
data.config_profile_id = None
|
||||
data.ssh_key_ids = []
|
||||
|
||||
await create_instance(
|
||||
project_id=fake_project_id,
|
||||
@@ -411,8 +419,10 @@ class TestStartInstanceLegacyFallback:
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@@ -423,8 +433,10 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_backend_network,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -440,7 +452,6 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -509,8 +520,10 @@ class TestStartInstanceLegacyFallback:
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@@ -521,8 +534,10 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_backend_network,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -538,7 +553,6 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -606,8 +620,10 @@ class TestStartInstanceLegacyFallback:
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@@ -618,8 +634,10 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_backend_network,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -635,7 +653,6 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -705,12 +722,15 @@ class TestStartInstanceSshPermissions:
|
||||
"""SSH key mounts trigger permission fixes after container starts."""
|
||||
|
||||
@patch("src.api.tool_instances.write_compose_file")
|
||||
@patch("src.api.tool_instances.prepare_ssh_key_files")
|
||||
@patch("src.api.tool_instances.apply_ssh_permissions")
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
@@ -719,12 +739,15 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_backend_network,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
mock_apply_ssh,
|
||||
mock_prepare_ssh,
|
||||
mock_write_compose,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
@@ -743,7 +766,6 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -815,33 +837,37 @@ class TestStartInstanceSshPermissions:
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
with patch(
|
||||
"src.api.tool_instances._prepare_manifest_instance"
|
||||
) as mock_prepare:
|
||||
mock_prepare.return_value = (
|
||||
"headquarter/test:latest",
|
||||
"services:\n app:\n image: test",
|
||||
{"name": "test-manifest", "user": {"name": "user"}},
|
||||
"/home/user",
|
||||
)
|
||||
result = await start_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
instance_id=fake_instance_id,
|
||||
data=None,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
with patch("os.makedirs"):
|
||||
with patch(
|
||||
"src.api.tool_instances._prepare_manifest_instance"
|
||||
) as mock_prepare:
|
||||
mock_prepare.return_value = (
|
||||
"headquarter/test:latest",
|
||||
"services:\n app:\n image: test",
|
||||
{"name": "test-manifest", "user": {"name": "user"}},
|
||||
"/home/user",
|
||||
)
|
||||
result = await start_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
instance_id=fake_instance_id,
|
||||
data=None,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert result["status"] == "running"
|
||||
mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user")
|
||||
|
||||
@patch("src.api.tool_instances.prepare_ssh_key_files")
|
||||
@patch("src.api.tool_instances.apply_ssh_permissions")
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
@@ -850,12 +876,15 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_backend_network,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
mock_apply_ssh,
|
||||
mock_prepare_ssh,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
@@ -870,7 +899,6 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -933,14 +961,16 @@ class TestStartInstanceSshPermissions:
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
result = await start_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
instance_id=fake_instance_id,
|
||||
data=None,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
with patch("os.makedirs"):
|
||||
with patch("src.api.tool_instances._modify_compose_file"):
|
||||
result = await start_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
instance_id=fake_instance_id,
|
||||
data=None,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert result["status"] == "running"
|
||||
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
|
||||
@@ -952,8 +982,10 @@ class TestStartInstanceManifestBranch:
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances.write_compose_file")
|
||||
@@ -966,8 +998,10 @@ class TestStartInstanceManifestBranch:
|
||||
mock_write_compose,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_backend_network,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -987,7 +1021,6 @@ class TestStartInstanceManifestBranch:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
|
||||
@@ -15,6 +15,12 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Never cache index.html so browsers always fetch new hashed JS/CSS
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
|
||||
Generated
+12
-12
@@ -16,11 +16,11 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-web-links": "^0.9.0"
|
||||
"xterm-addon-web-links": "^0.9.0",
|
||||
"xterm-addon-webgl": "^0.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -5469,16 +5469,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -6324,6 +6314,16 @@
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-webgl": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-webgl/-/xterm-addon-webgl-0.16.0.tgz",
|
||||
"integrity": "sha512-E8cq1AiqNOv0M/FghPT+zPAEnvIQRDbAbkb04rRYSxUym69elPWVJ4sv22FCLBqM/3LcrmBLl/pELnBebVFKgA==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-webgl instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"tailwindcss": "^3.3.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-web-links": "^0.9.0"
|
||||
"xterm-addon-web-links": "^0.9.0",
|
||||
"xterm-addon-webgl": "^0.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
||||
@@ -1,51 +1,54 @@
|
||||
import { apiClient } from "./client";
|
||||
import type { Project } from "../types";
|
||||
import type { Project, ProjectWithRepos } from "../types";
|
||||
|
||||
export type ProjectCreateInput = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type SetDefaultSSHKeyInput = {
|
||||
ssh_key_id: string;
|
||||
ssh_key_id: string;
|
||||
};
|
||||
|
||||
export const listProjects = async (): Promise<Project[]> => {
|
||||
const response = await apiClient.get<Project[]>("/projects");
|
||||
return response.data;
|
||||
export const listProjects = async (): Promise<ProjectWithRepos[]> => {
|
||||
const response = await apiClient.get<ProjectWithRepos[]>("/projects");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createProject = async (
|
||||
input: ProjectCreateInput
|
||||
input: ProjectCreateInput,
|
||||
): Promise<Project> => {
|
||||
const response = await apiClient.post<Project>("/projects", input);
|
||||
return response.data;
|
||||
const response = await apiClient.post<Project>("/projects", input);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProject = async (
|
||||
projectId: string,
|
||||
input: ProjectUpdateInput
|
||||
projectId: string,
|
||||
input: ProjectUpdateInput,
|
||||
): Promise<Project> => {
|
||||
const response = await apiClient.patch<Project>(`/projects/${projectId}`, input);
|
||||
return response.data;
|
||||
const response = await apiClient.patch<Project>(
|
||||
`/projects/${projectId}`,
|
||||
input,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteProject = async (projectId: string): Promise<void> => {
|
||||
await apiClient.delete(`/projects/${projectId}`);
|
||||
await apiClient.delete(`/projects/${projectId}`);
|
||||
};
|
||||
|
||||
export const setDefaultSSHKey = async (
|
||||
projectId: string,
|
||||
input: SetDefaultSSHKeyInput
|
||||
projectId: string,
|
||||
input: SetDefaultSSHKeyInput,
|
||||
): Promise<Project> => {
|
||||
const response = await apiClient.patch<Project>(
|
||||
`/projects/${projectId}/default-ssh-key`,
|
||||
input
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.patch<Project>(
|
||||
`/projects/${projectId}/default-ssh-key`,
|
||||
input,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
+154
-140
@@ -2,185 +2,199 @@ import { AxiosError } from "axios";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ToolInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
tool_type_id: string;
|
||||
tool_type_name: string;
|
||||
tool_type_interfaces: string[];
|
||||
status: string;
|
||||
url: string | null;
|
||||
port: number | null;
|
||||
selected_config_profile_id: string | null;
|
||||
ssh_key_ids: string[];
|
||||
created_at: string;
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
tool_type_id: string;
|
||||
tool_type_name: string;
|
||||
tool_type_interfaces: string[];
|
||||
status: string;
|
||||
url: string | null;
|
||||
port: number | null;
|
||||
selected_config_profile_id: string | null;
|
||||
ssh_key_ids: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
display_name: string;
|
||||
tool_type_name: string;
|
||||
tool_icon: string;
|
||||
tool_type_interfaces: string[];
|
||||
repository_name: string;
|
||||
repository_id: string;
|
||||
project_name: string;
|
||||
project_id: string;
|
||||
status: string;
|
||||
url: string | null;
|
||||
container_status?: string;
|
||||
probe_status?: string;
|
||||
clone_mode?: string;
|
||||
branch?: string | null;
|
||||
created_at?: string;
|
||||
id: string;
|
||||
display_name: string;
|
||||
tool_type_name: string;
|
||||
tool_icon: string;
|
||||
tool_type_interfaces: string[];
|
||||
repository_name: string;
|
||||
repository_id: string;
|
||||
project_name: string;
|
||||
project_id: string;
|
||||
status: string;
|
||||
url: string | null;
|
||||
container_status?: string;
|
||||
probe_status?: string;
|
||||
clone_mode?: string;
|
||||
branch?: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export async function listInstances(
|
||||
projectId: string,
|
||||
repoId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
): Promise<ToolInstance[]> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`
|
||||
);
|
||||
return response.data.instances;
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||
);
|
||||
return response.data.instances;
|
||||
}
|
||||
|
||||
export async function createInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
cloneMode?: string,
|
||||
branch?: string,
|
||||
newBranch?: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
workspaceId?: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
cloneMode?: string,
|
||||
branch?: string,
|
||||
newBranch?: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
workspaceId?: string,
|
||||
): Promise<ToolInstance> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||
{
|
||||
tool_type_id: toolTypeId,
|
||||
display_name: displayName,
|
||||
workspace_id: workspaceId || undefined,
|
||||
clone_mode: cloneMode || "mount",
|
||||
branch: branch || undefined,
|
||||
new_branch: newBranch || undefined,
|
||||
config_profile_id: configProfileId,
|
||||
ssh_key_ids: sshKeyIds || [],
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||
{
|
||||
tool_type_id: toolTypeId,
|
||||
display_name: displayName,
|
||||
workspace_id: workspaceId || undefined,
|
||||
clone_mode: cloneMode || "mount",
|
||||
branch: branch || undefined,
|
||||
new_branch: newBranch || undefined,
|
||||
config_profile_id: configProfileId,
|
||||
ssh_key_ids: sshKeyIds || [],
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function startInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
retries = 2
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
retries = 2,
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||
const axiosError = error as AxiosError;
|
||||
if (retries > 0 && !axiosError.response) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||
const axiosError = error as AxiosError;
|
||||
if (retries > 0 && !axiosError.response) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return startInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
instanceId,
|
||||
configProfileId,
|
||||
sshKeyIds,
|
||||
retries - 1,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
): Promise<{ status: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function restartInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
retries = 2
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
retries = 2,
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||
const axiosError = error as AxiosError;
|
||||
if (retries > 0 && !axiosError.response) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||
const axiosError = error as AxiosError;
|
||||
if (retries > 0 && !axiosError.response) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return restartInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
instanceId,
|
||||
configProfileId,
|
||||
sshKeyIds,
|
||||
retries - 1,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
force?: boolean
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
force?: boolean,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||
{ params: { force } }
|
||||
);
|
||||
await apiClient.delete(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||
{ params: { force } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserSessions(): Promise<Session[]> {
|
||||
const response = await apiClient.get("/users/me/sessions");
|
||||
return response.data.sessions;
|
||||
const response = await apiClient.get("/users/me/sessions");
|
||||
return response.data.sessions;
|
||||
}
|
||||
|
||||
export interface InstanceHealth {
|
||||
healthy: boolean;
|
||||
container_status: string;
|
||||
container_health: string | null;
|
||||
container_exit_code: number | null;
|
||||
tunnel_status: string;
|
||||
tunnel_status_code: number | null;
|
||||
probe_status: string;
|
||||
last_probe_output: string | null;
|
||||
error: string | null;
|
||||
healthy: boolean;
|
||||
container_status: string;
|
||||
container_health: string | null;
|
||||
container_exit_code: number | null;
|
||||
tunnel_status: string;
|
||||
tunnel_status_code: number | null;
|
||||
probe_status: string;
|
||||
last_probe_output: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function checkInstanceHealth(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
): Promise<InstanceHealth> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function recreateInstanceTunnel(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Workspace file API client. */
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export async function listWorkspaceFiles(
|
||||
workspaceId: string,
|
||||
path: string = "",
|
||||
): Promise<FileEntry[]> {
|
||||
const response = await apiClient.get<{ entries: FileEntry[] }>(
|
||||
`/workspaces/${workspaceId}/files/`,
|
||||
{ params: { path } },
|
||||
);
|
||||
return response.data.entries;
|
||||
}
|
||||
|
||||
export async function getWorkspaceFileContent(
|
||||
workspaceId: string,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
const response = await apiClient.get<{ content: string }>(
|
||||
`/workspaces/${workspaceId}/files/content`,
|
||||
{ params: { path } },
|
||||
);
|
||||
return response.data.content;
|
||||
}
|
||||
|
||||
export async function saveWorkspaceFile(
|
||||
workspaceId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
commitMessage?: string,
|
||||
): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/files/content`, {
|
||||
path,
|
||||
content,
|
||||
message: commitMessage,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/** Workspace git API client. */
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface GitStatus {
|
||||
branch: string;
|
||||
modified: string[];
|
||||
added: string[];
|
||||
deleted: string[];
|
||||
untracked: string[];
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
export interface Commit {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export async function getGitStatus(workspaceId: string): Promise<GitStatus> {
|
||||
const response = await apiClient.get<GitStatus>(
|
||||
`/workspaces/${workspaceId}/git/status`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getGitBranches(
|
||||
workspaceId: string,
|
||||
): Promise<{ branches: string[]; current_branch: string }> {
|
||||
const response = await apiClient.get<{
|
||||
branches: string[];
|
||||
current_branch: string;
|
||||
}>(`/workspaces/${workspaceId}/git/branches`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function gitCommit(
|
||||
workspaceId: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/git/commit`, { message });
|
||||
}
|
||||
|
||||
export async function gitPush(workspaceId: string): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/git/push`);
|
||||
}
|
||||
|
||||
export async function gitPull(workspaceId: string): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/git/pull`);
|
||||
}
|
||||
|
||||
export async function gitFetch(workspaceId: string): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/git/fetch`);
|
||||
}
|
||||
|
||||
export async function gitCheckout(
|
||||
workspaceId: string,
|
||||
branch: string,
|
||||
): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/git/checkout`, { branch });
|
||||
}
|
||||
|
||||
export async function getGitHistory(
|
||||
workspaceId: string,
|
||||
path?: string,
|
||||
limit: number = 50,
|
||||
): Promise<Commit[]> {
|
||||
const response = await apiClient.get<{ commits: Commit[] }>(
|
||||
`/workspaces/${workspaceId}/git/history`,
|
||||
{ params: { path, limit } },
|
||||
);
|
||||
return response.data.commits;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Workspace instance API client. */
|
||||
|
||||
import { apiClient } from "./client";
|
||||
import type { ToolInstance } from "./sessions";
|
||||
|
||||
export async function listWorkspaceInstances(
|
||||
workspaceId: string,
|
||||
): Promise<ToolInstance[]> {
|
||||
const response = await apiClient.get<ToolInstance[]>(
|
||||
`/workspaces/${workspaceId}/instances/`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createWorkspaceInstance(
|
||||
workspaceId: string,
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
configProfileId?: string,
|
||||
): Promise<ToolInstance> {
|
||||
const response = await apiClient.post<ToolInstance>(
|
||||
`/workspaces/${workspaceId}/instances/`,
|
||||
{
|
||||
tool_type_id: toolTypeId,
|
||||
display_name: displayName,
|
||||
config_profile_id: configProfileId,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -1,65 +1,92 @@
|
||||
/** Workspace API client. */
|
||||
|
||||
import { apiClient } from "./client";
|
||||
import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace";
|
||||
import type {
|
||||
Workspace,
|
||||
CreateWorkspaceRequest,
|
||||
SyncResult,
|
||||
} from "../types/workspace";
|
||||
|
||||
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
|
||||
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
|
||||
return workspaceId ? `${base}/${workspaceId}` : base;
|
||||
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
|
||||
return workspaceId ? `${base}/${workspaceId}` : `${base}/`;
|
||||
}
|
||||
|
||||
export async function listWorkspaces(projectId: string, repoId: string): Promise<Workspace[]> {
|
||||
const response = await apiClient.get<Workspace[]>(workspaceUrl(projectId, repoId));
|
||||
return response.data;
|
||||
export async function listWorkspaces(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
): Promise<Workspace[]> {
|
||||
const response = await apiClient.get<Workspace[]>(
|
||||
workspaceUrl(projectId, repoId),
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listAllWorkspaces(): Promise<Workspace[]> {
|
||||
const response = await apiClient.get<Workspace[]>("/workspaces/");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createWorkspace(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
data: CreateWorkspaceRequest,
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
data: CreateWorkspaceRequest,
|
||||
): Promise<Workspace> {
|
||||
const response = await apiClient.post<Workspace>(workspaceUrl(projectId, repoId), data);
|
||||
return response.data;
|
||||
const response = await apiClient.post<Workspace>(
|
||||
workspaceUrl(projectId, repoId),
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createWorkspaceTopLevel(
|
||||
data: CreateWorkspaceRequest & { repo_id: string },
|
||||
): Promise<Workspace> {
|
||||
const response = await apiClient.post<Workspace>("/workspaces/", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getWorkspace(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
): Promise<Workspace> {
|
||||
const response = await apiClient.get<Workspace>(workspaceUrl(projectId, repoId, workspaceId));
|
||||
return response.data;
|
||||
const response = await apiClient.get<Workspace>(
|
||||
workspaceUrl(projectId, repoId, workspaceId),
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateWorkspace(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
data: Partial<CreateWorkspaceRequest>,
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
data: Partial<CreateWorkspaceRequest>,
|
||||
): Promise<Workspace> {
|
||||
const response = await apiClient.patch<Workspace>(workspaceUrl(projectId, repoId, workspaceId), data);
|
||||
return response.data;
|
||||
const response = await apiClient.patch<Workspace>(
|
||||
workspaceUrl(projectId, repoId, workspaceId),
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
force = false,
|
||||
workspaceId: string,
|
||||
force = false,
|
||||
): Promise<{ status: string }> {
|
||||
const response = await apiClient.delete<{ status: string }>(
|
||||
`${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.delete<{ status: string }>(
|
||||
`/workspaces/${workspaceId}?force=${force}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function syncWorkspace(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
): Promise<SyncResult> {
|
||||
const response = await apiClient.post<SyncResult>(
|
||||
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post<SyncResult>(
|
||||
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { EventToastBridge } from "./event-toast-bridge";
|
||||
import { NotificationCenter } from "./notification-center";
|
||||
import { Icon } from "./icon";
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
import { StartToolFAB } from "./start-tool-fab";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
const NAV_ITEMS: {
|
||||
@@ -34,11 +35,23 @@ const NAV_ITEMS: {
|
||||
const SessionItem = ({ session }: { session: Session }) => {
|
||||
const isRunning = session.status === "running";
|
||||
|
||||
// Determine the link target:
|
||||
// - Web tools open their tunnel URL
|
||||
// - Terminal tools open the terminal page
|
||||
// - Everything else falls back to the project page
|
||||
const hasTerminal = session.tool_type_interfaces.includes("terminal");
|
||||
const hasWeb = session.tool_type_interfaces.includes("web");
|
||||
const href = session.url && hasWeb
|
||||
? session.url
|
||||
: hasTerminal
|
||||
? `/instances/${session.id}/terminal`
|
||||
: `/projects/${session.project_id}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={session.url ?? `/projects/${session.project_id}`}
|
||||
target={session.url ? "_blank" : undefined}
|
||||
rel={session.url ? "noopener noreferrer" : undefined}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-item session-item"
|
||||
title={`${session.display_name} (${session.status})`}
|
||||
>
|
||||
@@ -170,6 +183,7 @@ export const AppShell = () => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<StartToolFAB />
|
||||
</div>
|
||||
</NotificationProvider>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
ArrowLeft,
|
||||
DotsSixVertical,
|
||||
Bell,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
export type IconName =
|
||||
@@ -79,7 +81,9 @@ export type IconName =
|
||||
| "terminal"
|
||||
| "arrow-left"
|
||||
| "drag"
|
||||
| "bell";
|
||||
| "bell"
|
||||
| "chevron-down"
|
||||
| "chevron-right";
|
||||
|
||||
const iconMap: Record<
|
||||
IconName,
|
||||
@@ -129,6 +133,8 @@ const iconMap: Record<
|
||||
"arrow-left": ArrowLeft,
|
||||
drag: DotsSixVertical,
|
||||
bell: Bell,
|
||||
"chevron-down": CaretDown,
|
||||
"chevron-right": CaretRight,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
|
||||
@@ -58,6 +58,11 @@ export function SessionCard({
|
||||
const isTerminalOnly =
|
||||
session.tool_type_interfaces?.includes("terminal") &&
|
||||
!session.tool_type_interfaces?.includes("web");
|
||||
const openHref = session.url
|
||||
? session.url
|
||||
: isTerminalOnly
|
||||
? `/instances/${session.id}/terminal`
|
||||
: undefined;
|
||||
const hasTunnelError =
|
||||
!isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
|
||||
const hasAppError =
|
||||
@@ -150,9 +155,9 @@ export function SessionCard({
|
||||
<div className="session-card-actions mobile">
|
||||
{isActive && (
|
||||
<>
|
||||
{session.url ? (
|
||||
{openHref ? (
|
||||
<a
|
||||
href={session.url}
|
||||
href={openHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button mobile-primary"
|
||||
@@ -207,9 +212,9 @@ export function SessionCard({
|
||||
<div className="session-card-actions">
|
||||
{isActive && (
|
||||
<>
|
||||
{session.url ? (
|
||||
{openHref ? (
|
||||
<a
|
||||
href={session.url}
|
||||
href={openHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button small"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/** Floating action button to start a tool from any page. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { ToolStarter } from "./tool-starter";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
import { listAllWorkspaces } from "../api/workspaces";
|
||||
|
||||
export function StartToolFAB() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [workspacesLoading, setWorkspacesLoading] = useState(false);
|
||||
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleOpen = async () => {
|
||||
setOpen(true);
|
||||
setWorkspacesLoading(true);
|
||||
try {
|
||||
const data = await listAllWorkspaces();
|
||||
setWorkspaces(data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setWorkspacesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setSelectedWorkspace(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="start-tool-fab"
|
||||
onClick={handleOpen}
|
||||
title="Start a new tool"
|
||||
type="button"
|
||||
aria-label="Start a new tool"
|
||||
>
|
||||
<Icon name="play" size="md" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="modal-overlay" onClick={handleClose}>
|
||||
<div
|
||||
className="modal-content start-tool-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>Start Tool</h3>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workspacesLoading ? (
|
||||
<p className="muted">Loading workspaces...</p>
|
||||
) : workspaces.length === 0 ? (
|
||||
<p className="muted">
|
||||
No workspaces yet.{" "}
|
||||
<a href="/workspaces">Create a workspace first</a>.
|
||||
</p>
|
||||
) : !selectedWorkspace ? (
|
||||
<div className="form-group">
|
||||
<label htmlFor="fab-workspace-select">Select a workspace</label>
|
||||
<select
|
||||
id="fab-workspace-select"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const ws = workspaces.find((w) => w.id === e.target.value);
|
||||
if (ws) setSelectedWorkspace(ws);
|
||||
}}
|
||||
>
|
||||
<option value="">Choose a workspace...</option>
|
||||
{workspaces.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
{ws.project_name} / {ws.repo_name} / {ws.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="tool-starter-header">
|
||||
<h4>
|
||||
{selectedWorkspace.project_name} /{" "}
|
||||
{selectedWorkspace.repo_name} / {selectedWorkspace.name}
|
||||
</h4>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setSelectedWorkspace(null)}
|
||||
type="button"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
<ToolStarter
|
||||
workspace={selectedWorkspace}
|
||||
onStarted={handleClose}
|
||||
onCancel={() => setSelectedWorkspace(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,86 +2,113 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
export interface StartToolModalProps {
|
||||
workspace: Workspace;
|
||||
onClose: () => void;
|
||||
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
|
||||
workspace: Workspace;
|
||||
onClose: () => void;
|
||||
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) {
|
||||
const [toolTypeId, setToolTypeId] = useState("");
|
||||
const [configProfileId, setConfigProfileId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export function StartToolModal({
|
||||
workspace,
|
||||
onClose,
|
||||
onStart,
|
||||
}: StartToolModalProps) {
|
||||
const [toolTypeId, setToolTypeId] = useState("");
|
||||
const [configProfileId, setConfigProfileId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!toolTypeId) {
|
||||
setError("Please select a tool type");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onStart(toolTypeId, configProfileId || undefined);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to start tool");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const {
|
||||
data: toolTypes,
|
||||
status,
|
||||
error: loadError,
|
||||
} = useAsyncData<ToolType[]>(listToolTypes, []);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
<Icon name="play" size="sm" /> Start Tool on {workspace.name}
|
||||
</h3>
|
||||
<button className="btn btn-icon" onClick={onClose}>
|
||||
<Icon name="cancel" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type">Tool Type</label>
|
||||
<select
|
||||
id="tool-type"
|
||||
value={toolTypeId}
|
||||
onChange={(e) => setToolTypeId(e.target.value)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="">Select a tool...</option>
|
||||
<option value="code-server">Code Server</option>
|
||||
<option value="jupyter-notebook">Jupyter Notebook</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-profile">Config Profile (optional)</label>
|
||||
<input
|
||||
id="config-profile"
|
||||
type="text"
|
||||
value={configProfileId}
|
||||
onChange={(e) => setConfigProfileId(e.target.value)}
|
||||
placeholder="Profile ID"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Starting..." : "Start Tool"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!toolTypeId) {
|
||||
setError("Please select a tool type");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onStart(toolTypeId, configProfileId || undefined);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to start tool");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
<Icon name="play" size="sm" /> Start Tool on {workspace.name}
|
||||
</h3>
|
||||
<button className="btn btn-icon" onClick={onClose}>
|
||||
<Icon name="cancel" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type">Tool Type</label>
|
||||
<select
|
||||
id="tool-type"
|
||||
value={toolTypeId}
|
||||
onChange={(e) => setToolTypeId(e.target.value)}
|
||||
disabled={submitting || status === "loading"}
|
||||
>
|
||||
<option value="">Select a tool...</option>
|
||||
{toolTypes?.map((tt) => (
|
||||
<option key={tt.id} value={tt.id}>
|
||||
{tt.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{status === "loading" && (
|
||||
<span className="muted">Loading tools...</span>
|
||||
)}
|
||||
{loadError && <span className="error-text">{loadError}</span>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-profile">Config Profile (optional)</label>
|
||||
<input
|
||||
id="config-profile"
|
||||
type="text"
|
||||
value={configProfileId}
|
||||
onChange={(e) => setConfigProfileId(e.target.value)}
|
||||
placeholder="Profile ID"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting || status !== "ready"}
|
||||
>
|
||||
{submitting ? "Starting..." : "Start Tool"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import { WebglAddon } from "xterm-addon-webgl";
|
||||
import "xterm/css/xterm.css";
|
||||
|
||||
import {
|
||||
@@ -107,6 +108,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
|
||||
// WebSocket connection established
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -137,14 +139,35 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
// Flow control: accumulate processed bytes and send ack
|
||||
let ackAccumulator = 0;
|
||||
const ACK_THRESHOLD = 4096;
|
||||
let ackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flushAck = () => {
|
||||
if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator }));
|
||||
ackAccumulator = 0;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!termRef.current) return;
|
||||
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
const data = new Uint8Array(buffer);
|
||||
termRef.current?.write(data);
|
||||
});
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const data = new Uint8Array(event.data);
|
||||
termRef.current.write(data);
|
||||
|
||||
// Flow control: accumulate processed bytes
|
||||
ackAccumulator += data.length;
|
||||
if (ackAccumulator >= ACK_THRESHOLD) {
|
||||
flushAck();
|
||||
} else if (!ackTimeout) {
|
||||
ackTimeout = setTimeout(() => {
|
||||
ackTimeout = null;
|
||||
flushAck();
|
||||
}, 100);
|
||||
}
|
||||
} else if (typeof event.data === "string") {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
@@ -251,6 +274,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: 0,
|
||||
allowTransparency: false,
|
||||
scrollback: 10000,
|
||||
ignoreBracketedPasteMode: false,
|
||||
fastScrollSensitivity: 5,
|
||||
scrollSensitivity: 1,
|
||||
smoothScrollDuration: 0,
|
||||
theme: {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
@@ -282,9 +310,31 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
// Load WebGL renderer for GPU acceleration, fall back to DOM
|
||||
let webglAddon: WebglAddon | null = null;
|
||||
try {
|
||||
webglAddon = new WebglAddon();
|
||||
term.loadAddon(webglAddon);
|
||||
webglAddon.onContextLoss(() => {
|
||||
console.warn("WebGL context lost, falling back to DOM renderer");
|
||||
try {
|
||||
webglAddon?.dispose();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
webglAddon = null;
|
||||
// Trigger a refit since cell dimensions may differ
|
||||
requestAnimationFrame(() => fitTerminal());
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("WebGL renderer failed to load, using DOM renderer", e);
|
||||
}
|
||||
|
||||
const container = terminalRef.current;
|
||||
|
||||
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
||||
let lastSentCols = 0;
|
||||
let lastSentRows = 0;
|
||||
const fitTerminal = () => {
|
||||
if (!fitAddonRef.current || !termRef.current) return;
|
||||
try {
|
||||
@@ -294,18 +344,19 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
return;
|
||||
}
|
||||
const { cols, rows } = termRef.current;
|
||||
// Force refresh if dimensions are valid
|
||||
if (cols > 0 && rows > 0) {
|
||||
try {
|
||||
termRef.current.refresh(0, rows - 1);
|
||||
} catch {
|
||||
// Ignore refresh errors
|
||||
// Only send resize when dimensions actually changed
|
||||
if (
|
||||
cols > 0 &&
|
||||
rows > 0 &&
|
||||
(cols !== lastSentCols || rows !== lastSentRows)
|
||||
) {
|
||||
lastSentCols = cols;
|
||||
lastSentRows = rows;
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
}
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) {
|
||||
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
};
|
||||
|
||||
// Open xterm first (must happen before fit)
|
||||
@@ -352,16 +403,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
// If the viewport is scrollable, scroll it directly.
|
||||
// Otherwise we are in alternate screen (tmux/vim) and must
|
||||
// send SGR 1006 mouse-wheel protocol data.
|
||||
const hasScrollback =
|
||||
viewport.scrollHeight > viewport.clientHeight;
|
||||
const hasScrollback = viewport.scrollHeight > viewport.clientHeight;
|
||||
if (hasScrollback) {
|
||||
viewport.scrollTop += deltaY;
|
||||
} else {
|
||||
const ws = wsRef.current;
|
||||
if (
|
||||
ws?.readyState === WebSocket.OPEN &&
|
||||
termRef.current
|
||||
) {
|
||||
if (ws?.readyState === WebSocket.OPEN && termRef.current) {
|
||||
// Use the cursor position as the wheel location so
|
||||
// tmux knows which pane to scroll.
|
||||
const buf = termRef.current.buffer.active;
|
||||
@@ -538,7 +585,21 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
window.clearInterval(heartbeatCheckRef.current);
|
||||
heartbeatCheckRef.current = null;
|
||||
}
|
||||
term.dispose();
|
||||
// Dispose WebGL addon BEFORE the terminal to avoid race with
|
||||
// RenderService.setRenderer accessing a disposed renderer
|
||||
if (webglAddon) {
|
||||
try {
|
||||
webglAddon.dispose();
|
||||
} catch {
|
||||
// Ignore disposal errors from partially torn-down terminal
|
||||
}
|
||||
webglAddon = null;
|
||||
}
|
||||
try {
|
||||
term.dispose();
|
||||
} catch {
|
||||
// Ignore disposal errors from partially torn-down terminal
|
||||
}
|
||||
};
|
||||
}, [instanceId, connectWebSocket]);
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/** Unified tool starter — workspace-first, fetches real tool types and config profiles. */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
import type { ToolInstance } from "../api/sessions";
|
||||
|
||||
export interface ToolStarterProps {
|
||||
workspace: Workspace;
|
||||
onStarted: (instance: ToolInstance) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function ToolStarter({
|
||||
workspace,
|
||||
onStarted,
|
||||
onCancel,
|
||||
}: ToolStarterProps) {
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [toolTypesLoading, setToolTypesLoading] = useState(true);
|
||||
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
|
||||
|
||||
const [selectedToolTypeId, setSelectedToolTypeId] = useState("");
|
||||
|
||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [profilesLoading, setProfilesLoading] = useState(false);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
const [sshKeysLoading, setSshKeysLoading] = useState(true);
|
||||
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
||||
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch tool types on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch (err) {
|
||||
setToolTypesError(
|
||||
err instanceof Error ? err.message : "Failed to load tool types",
|
||||
);
|
||||
} finally {
|
||||
setToolTypesLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
// Fetch config profiles when tool type changes
|
||||
useEffect(() => {
|
||||
if (!selectedToolTypeId) {
|
||||
setProfiles([]);
|
||||
setSelectedProfileId("");
|
||||
return;
|
||||
}
|
||||
const load = async () => {
|
||||
setProfilesLoading(true);
|
||||
try {
|
||||
const data = await listConfigProfiles(
|
||||
workspace.project_id,
|
||||
selectedToolTypeId,
|
||||
);
|
||||
setProfiles(data);
|
||||
// Auto-select default profile if available
|
||||
const defaultProfile = data.find((p) => p.is_default);
|
||||
if (defaultProfile) {
|
||||
setSelectedProfileId(defaultProfile.id);
|
||||
} else {
|
||||
setSelectedProfileId("");
|
||||
}
|
||||
} catch {
|
||||
setProfiles([]);
|
||||
} finally {
|
||||
setProfilesLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [selectedToolTypeId, workspace.project_id]);
|
||||
|
||||
// Fetch SSH keys on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await listSSHKeys();
|
||||
setSshKeys(data);
|
||||
// Auto-select the repository's SSH key if available
|
||||
if (workspace.repo_ssh_key_id) {
|
||||
setSelectedSshKeyIds([workspace.repo_ssh_key_id]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load SSH keys:", err);
|
||||
} finally {
|
||||
setSshKeysLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [workspace.repo_ssh_key_id]);
|
||||
|
||||
const repoHasSshKey = !!workspace.repo_ssh_key_id;
|
||||
const repoSshKey = sshKeys.find((k) => k.id === workspace.repo_ssh_key_id);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
if (!selectedToolTypeId) {
|
||||
setError("Please select a tool type");
|
||||
return;
|
||||
}
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { createInstance, startInstance } = await import("../api/sessions");
|
||||
const instance = await createInstance(
|
||||
workspace.project_id,
|
||||
workspace.repo_id,
|
||||
selectedToolTypeId,
|
||||
workspace.name,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
selectedProfileId || undefined,
|
||||
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
|
||||
workspace.id,
|
||||
);
|
||||
await startInstance(
|
||||
workspace.project_id,
|
||||
workspace.repo_id,
|
||||
instance.id,
|
||||
selectedProfileId || undefined,
|
||||
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
|
||||
);
|
||||
onStarted(instance);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to start tool");
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [selectedToolTypeId, selectedProfileId, workspace, onStarted]);
|
||||
|
||||
return (
|
||||
<div className="tool-starter">
|
||||
{/* Context header — read-only workspace info */}
|
||||
<div className="tool-starter-context">
|
||||
<div className="context-row">
|
||||
<span className="context-label">Project</span>
|
||||
<span className="context-value">{workspace.project_name}</span>
|
||||
</div>
|
||||
<div className="context-row">
|
||||
<span className="context-label">Repository</span>
|
||||
<span className="context-value">{workspace.repo_name}</span>
|
||||
</div>
|
||||
<div className="context-row">
|
||||
<span className="context-label">Workspace</span>
|
||||
<span className="context-value">{workspace.name}</span>
|
||||
<span className="branch-badge">
|
||||
<Icon name="branch" size="sm" /> {workspace.branch}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool Type */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type">Tool Type</label>
|
||||
<select
|
||||
id="tool-type"
|
||||
value={selectedToolTypeId}
|
||||
onChange={(e) => {
|
||||
setSelectedToolTypeId(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={toolTypesLoading || starting}
|
||||
>
|
||||
<option value="">Select a tool...</option>
|
||||
{toolTypes.map((tt) => (
|
||||
<option key={tt.id} value={tt.id}>
|
||||
{tt.display_name}
|
||||
{tt.category && ` (${tt.category})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{toolTypesLoading && <span className="muted">Loading tools...</span>}
|
||||
{toolTypesError && <span className="error-text">{toolTypesError}</span>}
|
||||
</div>
|
||||
|
||||
{/* Config Profile */}
|
||||
{selectedToolTypeId && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-profile">Config Profile</label>
|
||||
<select
|
||||
id="config-profile"
|
||||
value={selectedProfileId}
|
||||
onChange={(e) => setSelectedProfileId(e.target.value)}
|
||||
disabled={profilesLoading || starting}
|
||||
>
|
||||
<option value="">Default (no profile)</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.is_default && " (default)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{profilesLoading && (
|
||||
<span className="muted">Loading profiles...</span>
|
||||
)}
|
||||
{profiles.length === 0 && !profilesLoading && (
|
||||
<span className="muted">No custom profiles for this tool.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Key Selection */}
|
||||
<div className="form-group ssh-key-selection">
|
||||
<label>SSH Keys</label>
|
||||
{sshKeysLoading ? (
|
||||
<span className="muted">Loading SSH keys...</span>
|
||||
) : sshKeys.length === 0 ? (
|
||||
<span className="muted">No SSH keys configured.</span>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||
{sshKeys.map((key) => (
|
||||
<label
|
||||
key={key.id}
|
||||
className="checkbox-label"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
padding: "0.375rem 0.75rem",
|
||||
background: "var(--panel)",
|
||||
borderRadius: "0.375rem",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSshKeyIds.includes(key.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedSshKeyIds((prev) => [...prev, key.id]);
|
||||
} else {
|
||||
setSelectedSshKeyIds((prev) =>
|
||||
prev.filter((id) => id !== key.id),
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={starting}
|
||||
/>
|
||||
{key.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!sshKeysLoading && repoHasSshKey && repoSshKey && (
|
||||
<div className="hint" style={{ marginTop: "0.5rem" }}>
|
||||
Repository key <strong>{repoSshKey.name}</strong> is pre-selected.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
|
||||
<div className="form-actions">
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
disabled={starting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleStart}
|
||||
disabled={!selectedToolTypeId || toolTypesLoading || starting}
|
||||
>
|
||||
{starting ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" /> Starting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="play" size="sm" /> Start Tool
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +1,77 @@
|
||||
/** Card component for displaying a workspace. */
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { Icon } from "./icon";
|
||||
import { WorkspaceInstanceChips } from "./workspace-instance-chips";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
export interface WorkspaceCardProps {
|
||||
workspace: Workspace;
|
||||
loading?: boolean;
|
||||
onStartTool: (workspace: Workspace) => void;
|
||||
onSync: (workspace: Workspace) => void;
|
||||
onDelete: (workspace: Workspace) => void;
|
||||
workspace: Workspace;
|
||||
loading?: boolean;
|
||||
onStartTool: (workspace: Workspace) => void;
|
||||
onSync: (workspace: Workspace) => void;
|
||||
onDelete: (workspace: Workspace) => void;
|
||||
}
|
||||
|
||||
export function WorkspaceCard({
|
||||
workspace,
|
||||
loading = false,
|
||||
onStartTool,
|
||||
onSync,
|
||||
onDelete,
|
||||
workspace,
|
||||
loading = false,
|
||||
onStartTool,
|
||||
onSync,
|
||||
onDelete,
|
||||
}: WorkspaceCardProps) {
|
||||
const statusClass =
|
||||
workspace.status === "ready"
|
||||
? "status-ready"
|
||||
: workspace.status === "syncing"
|
||||
? "status-syncing"
|
||||
: "status-error";
|
||||
const statusClass =
|
||||
workspace.status === "ready"
|
||||
? "status-ready"
|
||||
: workspace.status === "syncing"
|
||||
? "status-syncing"
|
||||
: "status-error";
|
||||
|
||||
return (
|
||||
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
||||
<div className="workspace-header">
|
||||
<h4>{workspace.name}</h4>
|
||||
<span className={`status-badge ${statusClass}`}>{workspace.status}</span>
|
||||
</div>
|
||||
<div className="workspace-meta">
|
||||
<p className="workspace-project">
|
||||
{workspace.project_name} / {workspace.repo_name}
|
||||
</p>
|
||||
<p className="workspace-branch">
|
||||
<Icon name="branch" size="sm" /> {workspace.branch}
|
||||
</p>
|
||||
{workspace.instance_count > 0 && (
|
||||
<p className="workspace-instances">
|
||||
{workspace.instance_count} active tool
|
||||
{workspace.instance_count > 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => onStartTool(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="play" size="sm" /> Start Tool
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onSync(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="refresh" size="sm" /> Sync
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => onDelete(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
return (
|
||||
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
||||
<Link
|
||||
to={`/workspaces/${workspace.id}`}
|
||||
className="workspace-header-link"
|
||||
>
|
||||
<div className="workspace-header">
|
||||
<h4>{workspace.name}</h4>
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
{workspace.status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="workspace-meta">
|
||||
<p className="workspace-project">
|
||||
{workspace.project_name} / {workspace.repo_name}
|
||||
</p>
|
||||
<p className="workspace-branch">
|
||||
<Icon name="branch" size="sm" /> {workspace.branch}
|
||||
</p>
|
||||
<WorkspaceInstanceChips workspaceId={workspace.id} />
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => onStartTool(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="play" size="sm" /> Start Tool
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onSync(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="refresh" size="sm" /> Sync
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => onDelete(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +1,321 @@
|
||||
/** Form for creating a new workspace. */
|
||||
/** Unified workspace creation form with project/repo/branch selectors. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import type { CreateWorkspaceRequest } from "../types/workspace";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories } from "../api/git_repositories";
|
||||
import { createWorkspaceTopLevel } from "../api/workspaces";
|
||||
import { useGitRepo } from "../hooks/use-git-repo";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
import type { GitRepository } from "../api/git_repositories";
|
||||
|
||||
export interface WorkspaceCreateFormProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
defaultBranch?: string;
|
||||
onSubmit: (data: CreateWorkspaceRequest) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
/** Called after successful creation. */
|
||||
onSubmit: () => void | Promise<void>;
|
||||
/** Cancel callback. */
|
||||
onCancel: () => void;
|
||||
/** Optional: pre-selected project ID (hides project selector). */
|
||||
defaultProjectId?: string;
|
||||
/** Optional: pre-selected repo ID (hides repo selector). */
|
||||
defaultRepoId?: string;
|
||||
}
|
||||
|
||||
export function WorkspaceCreateForm({
|
||||
defaultBranch = "main",
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
defaultProjectId,
|
||||
defaultRepoId,
|
||||
}: WorkspaceCreateFormProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [branch, setBranch] = useState(defaultBranch);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isContextual = Boolean(defaultProjectId && defaultRepoId);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setError("Workspace name is required");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit({ name: name.trim(), branch: branch.trim() });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create workspace");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [repos, setRepos] = useState<GitRepository[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState(
|
||||
defaultProjectId ?? "",
|
||||
);
|
||||
const [selectedRepo, setSelectedRepo] = useState(defaultRepoId ?? "");
|
||||
const [selectedBranch, setSelectedBranch] = useState("");
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [isNewBranch, setIsNewBranch] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fetchingProjects, setFetchingProjects] = useState(!isContextual);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<form className="workspace-create-form card" onSubmit={handleSubmit}>
|
||||
<h3>
|
||||
<Icon name="add" size="sm" /> Create Workspace
|
||||
</h3>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ws-name">Name</label>
|
||||
<input
|
||||
id="ws-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., feature-branch"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ws-branch">
|
||||
<Icon name="branch" size="sm" /> Branch
|
||||
</label>
|
||||
<input
|
||||
id="ws-branch"
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
/* Git repo hook handles branch fetching, loading, errors */
|
||||
const git = useGitRepo(
|
||||
selectedProject || undefined,
|
||||
selectedRepo || undefined,
|
||||
);
|
||||
|
||||
/* Sync local branch state with hook data */
|
||||
useEffect(() => {
|
||||
if (git.branches.length > 0 && !selectedBranch) {
|
||||
const preferred =
|
||||
git.defaultBranch && git.branches.includes(git.defaultBranch)
|
||||
? git.defaultBranch
|
||||
: git.branches[0];
|
||||
setSelectedBranch(preferred);
|
||||
setIsNewBranch(false);
|
||||
} else if (git.error && git.branches.length === 0 && !isNewBranch) {
|
||||
// API failed — default to manual entry so user can type a branch
|
||||
setIsNewBranch(true);
|
||||
setSelectedBranch("__manual__");
|
||||
}
|
||||
}, [git.branches, git.defaultBranch, git.error, selectedBranch, isNewBranch]);
|
||||
|
||||
/* ── Load projects (standalone mode only) ── */
|
||||
const loadProjects = useCallback(async () => {
|
||||
if (isContextual) return;
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
if (data.length === 1 && !defaultProjectId) {
|
||||
setSelectedProject(data[0].id);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load projects");
|
||||
} finally {
|
||||
setFetchingProjects(false);
|
||||
}
|
||||
}, [isContextual, defaultProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, [loadProjects]);
|
||||
|
||||
/* ── Load repos when project changes ── */
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepos([]);
|
||||
if (!defaultRepoId) setSelectedRepo("");
|
||||
return;
|
||||
}
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepos(data);
|
||||
if (data.length === 1 && !defaultRepoId) {
|
||||
setSelectedRepo(data[0].id);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load repositories");
|
||||
}
|
||||
};
|
||||
void loadRepos();
|
||||
}, [selectedProject, defaultRepoId]);
|
||||
|
||||
const handleBranchChange = (value: string) => {
|
||||
if (value === "__new__") {
|
||||
setIsNewBranch(true);
|
||||
setSelectedBranch("__new__");
|
||||
setNewBranchName("");
|
||||
} else if (value === "__manual__") {
|
||||
setIsNewBranch(true);
|
||||
setSelectedBranch("__manual__");
|
||||
setNewBranchName("");
|
||||
} else {
|
||||
setIsNewBranch(false);
|
||||
setSelectedBranch(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedRepo) {
|
||||
setError("Please select a repository");
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
setError("Workspace name is required");
|
||||
return;
|
||||
}
|
||||
const branchName = isNewBranch ? newBranchName.trim() : selectedBranch;
|
||||
if (!branchName) {
|
||||
setError("Please select or enter a branch");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createWorkspaceTopLevel({
|
||||
repo_id: selectedRepo,
|
||||
name: name.trim(),
|
||||
branch: branchName,
|
||||
});
|
||||
await onSubmit();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to create workspace",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* Show single combined error */
|
||||
const displayError = error || git.error;
|
||||
|
||||
if (fetchingProjects) {
|
||||
return (
|
||||
<div className="card workspace-create-inline">
|
||||
<p className="muted">Loading projects...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const branchSelectDisabled =
|
||||
!selectedRepo || submitting || (git.loading && git.branches.length === 0);
|
||||
|
||||
return (
|
||||
<div className="card workspace-create-inline">
|
||||
<h3>
|
||||
<Icon name="add" size="sm" /> Create Workspace
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="workspace-create-form-grid">
|
||||
{/* Project selector (standalone only) */}
|
||||
{!isContextual && (
|
||||
<div className="form-group">
|
||||
<label>Project</label>
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => {
|
||||
setSelectedProject(e.target.value);
|
||||
setSelectedBranch("");
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repo selector (standalone only) */}
|
||||
{!isContextual && (
|
||||
<div className="form-group">
|
||||
<label>Repository</label>
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(e) => {
|
||||
setSelectedRepo(e.target.value);
|
||||
setSelectedBranch("");
|
||||
}}
|
||||
required
|
||||
disabled={!selectedProject || repos.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{!selectedProject
|
||||
? "Select a project first"
|
||||
: repos.length === 0
|
||||
? "No repositories"
|
||||
: "Select repository..."}
|
||||
</option>
|
||||
{repos.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., feature-branch"
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Icon name="branch" size="sm" /> Branch
|
||||
</label>
|
||||
|
||||
{/* Show a hint when branches couldn’t be loaded */}
|
||||
{git.error && git.branches.length === 0 && selectedRepo && (
|
||||
<p
|
||||
className="muted"
|
||||
style={{
|
||||
fontSize: "var(--font-size-xs)",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
Couldn’t load branches — type one manually.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={selectedBranch}
|
||||
onChange={(e) => handleBranchChange(e.target.value)}
|
||||
required
|
||||
disabled={branchSelectDisabled}
|
||||
>
|
||||
<option value="">
|
||||
{git.loading && git.branches.length === 0
|
||||
? "Loading branches..."
|
||||
: !selectedRepo
|
||||
? "Select a repository first"
|
||||
: "Select branch..."}
|
||||
</option>
|
||||
|
||||
{git.branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
{b === git.defaultBranch ? " (default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
|
||||
<option value="__new__">+ Create new branch...</option>
|
||||
</select>
|
||||
|
||||
{/* Text input for new branch or manual entry */}
|
||||
{isNewBranch && (
|
||||
<input
|
||||
type="text"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
placeholder="new-branch-name"
|
||||
required
|
||||
style={{ marginTop: "0.5rem" }}
|
||||
disabled={submitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayError && (
|
||||
<div className="form-error" style={{ gridColumn: "1 / -1" }}>
|
||||
{displayError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions" style={{ gridColumn: "1 / -1" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting || !selectedRepo}
|
||||
>
|
||||
{submitting ? "Creating..." : "Create Workspace"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Small component showing running instances for a workspace. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { listWorkspaceInstances } from "../api/workspace-instances";
|
||||
import type { ToolInstance } from "../api/sessions";
|
||||
|
||||
interface WorkspaceInstanceChipsProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function WorkspaceInstanceChips({
|
||||
workspaceId,
|
||||
}: WorkspaceInstanceChipsProps) {
|
||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await listWorkspaceInstances(workspaceId);
|
||||
setInstances(data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [workspaceId]);
|
||||
|
||||
if (loading) return <span className="muted">...</span>;
|
||||
if (instances.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="instance-chips">
|
||||
{instances.map((inst) => (
|
||||
<span
|
||||
key={inst.id}
|
||||
className={`instance-chip ${inst.status}`}
|
||||
title={inst.display_name}
|
||||
>
|
||||
{inst.display_name}
|
||||
{inst.status === "running" && inst.url && (
|
||||
<a
|
||||
href={inst.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/** Unified hook for git repository operations.
|
||||
*
|
||||
* Centralizes branch fetching, status, history, and git actions
|
||||
* so components don't duplicate this logic.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
listRepositoryBranches,
|
||||
getRepositoryStatus,
|
||||
getRepositoryHistory,
|
||||
getCommitDetail,
|
||||
commitChanges,
|
||||
pushRepository,
|
||||
pullRepository,
|
||||
fetchRepository,
|
||||
checkoutBranch,
|
||||
createBranch,
|
||||
deleteBranch,
|
||||
mergeBranches,
|
||||
type Branch,
|
||||
type CommitHistoryResponse,
|
||||
type CommitDetail,
|
||||
} from "../api/git_repositories";
|
||||
|
||||
export interface GitStatus {
|
||||
branch: string;
|
||||
modified: string[];
|
||||
added: string[];
|
||||
deleted: string[];
|
||||
untracked: string[];
|
||||
renamed: string[];
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
export interface UseGitRepoResult {
|
||||
/** Available branch names. */
|
||||
branches: string[];
|
||||
/** The repo's default branch. */
|
||||
defaultBranch: string;
|
||||
/** Current working-directory status. */
|
||||
status: GitStatus | null;
|
||||
/** Commit history. */
|
||||
history: CommitHistoryResponse | null;
|
||||
/** Selected commit detail. */
|
||||
commitDetail: CommitDetail | null;
|
||||
/** True while any async operation is in flight. */
|
||||
loading: boolean;
|
||||
/** Error message from the last failed operation. */
|
||||
error: string | null;
|
||||
/** Refresh branches list. */
|
||||
refreshBranches: () => Promise<void>;
|
||||
/** Refresh working-directory status. */
|
||||
refreshStatus: () => Promise<void>;
|
||||
/** Refresh commit history. */
|
||||
refreshHistory: (branch?: string, limit?: number) => Promise<void>;
|
||||
/** Fetch a single commit's details. */
|
||||
loadCommitDetail: (hash: string) => Promise<void>;
|
||||
/** Stage + commit changes. */
|
||||
commit: (message: string, files?: string[]) => Promise<void>;
|
||||
/** Push current branch (or named branch) to remote. */
|
||||
push: (branch?: string) => Promise<void>;
|
||||
/** Pull from remote. */
|
||||
pull: (branch?: string) => Promise<void>;
|
||||
/** Fetch from remote. */
|
||||
fetch: () => Promise<void>;
|
||||
/** Checkout an existing branch. */
|
||||
checkout: (branch: string) => Promise<void>;
|
||||
/** Create and checkout a new branch. */
|
||||
createBranch: (name: string, baseBranch?: string) => Promise<void>;
|
||||
/** Delete a branch. */
|
||||
deleteBranch: (name: string, force?: boolean) => Promise<void>;
|
||||
/** Merge source into current (or target) branch. */
|
||||
merge: (
|
||||
sourceBranch: string,
|
||||
targetBranch?: string,
|
||||
message?: string,
|
||||
) => Promise<void>;
|
||||
/** Clear the current error. */
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export function useGitRepo(
|
||||
projectId: string | undefined,
|
||||
repoId: string | undefined,
|
||||
): UseGitRepoResult {
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [defaultBranch, setDefaultBranch] = useState("");
|
||||
const [status, setStatus] = useState<GitStatus | null>(null);
|
||||
const [history, setHistory] = useState<CommitHistoryResponse | null>(null);
|
||||
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const extractError = (err: unknown): string => {
|
||||
if (typeof err === "object" && err !== null) {
|
||||
const e = err as Record<string, unknown>;
|
||||
const response = e.response as Record<string, unknown> | undefined;
|
||||
const data = response?.data as Record<string, unknown> | undefined;
|
||||
if (typeof data?.detail === "string") return data.detail;
|
||||
if (typeof data?.message === "string") return data.message;
|
||||
if (typeof e.message === "string") return e.message;
|
||||
}
|
||||
return "Git operation failed";
|
||||
};
|
||||
|
||||
const withLoading = useCallback(
|
||||
async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
setError(extractError(err));
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshBranches = useCallback(async () => {
|
||||
if (!projectId || !repoId) return;
|
||||
const data = await withLoading(() =>
|
||||
listRepositoryBranches(projectId, repoId),
|
||||
);
|
||||
setBranches(data.branches.map((b: Branch) => b.name));
|
||||
setDefaultBranch(data.default_branch ?? "");
|
||||
}, [projectId, repoId, withLoading]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!projectId || !repoId) return;
|
||||
const data = await withLoading(() =>
|
||||
getRepositoryStatus(projectId, repoId),
|
||||
);
|
||||
setStatus({
|
||||
branch: data.branch,
|
||||
modified: data.modified,
|
||||
added: data.added,
|
||||
deleted: data.deleted,
|
||||
untracked: data.untracked,
|
||||
renamed: data.renamed ?? [],
|
||||
ahead: data.ahead,
|
||||
behind: data.behind,
|
||||
});
|
||||
}, [projectId, repoId, withLoading]);
|
||||
|
||||
const refreshHistory = useCallback(
|
||||
async (branch?: string, limit = 50) => {
|
||||
if (!projectId || !repoId) return;
|
||||
const data = await withLoading(() =>
|
||||
getRepositoryHistory(projectId, repoId, branch, limit),
|
||||
);
|
||||
setHistory(data);
|
||||
},
|
||||
[projectId, repoId, withLoading],
|
||||
);
|
||||
|
||||
const loadCommitDetail = useCallback(
|
||||
async (hash: string) => {
|
||||
if (!projectId || !repoId) return;
|
||||
const data = await withLoading(() =>
|
||||
getCommitDetail(projectId, repoId, hash),
|
||||
);
|
||||
setCommitDetail(data);
|
||||
},
|
||||
[projectId, repoId, withLoading],
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
async (message: string, files?: string[]) => {
|
||||
if (!projectId || !repoId) return;
|
||||
await withLoading(() => commitChanges(projectId, repoId, message, files));
|
||||
await refreshStatus();
|
||||
},
|
||||
[projectId, repoId, withLoading, refreshStatus],
|
||||
);
|
||||
|
||||
const push = useCallback(
|
||||
async (branch?: string) => {
|
||||
if (!projectId || !repoId) return;
|
||||
await withLoading(() => pushRepository(projectId, repoId, branch));
|
||||
await refreshStatus();
|
||||
},
|
||||
[projectId, repoId, withLoading, refreshStatus],
|
||||
);
|
||||
|
||||
const pull = useCallback(
|
||||
async (branch?: string) => {
|
||||
if (!projectId || !repoId) return;
|
||||
await withLoading(() => pullRepository(projectId, repoId, branch));
|
||||
await refreshStatus();
|
||||
},
|
||||
[projectId, repoId, withLoading, refreshStatus],
|
||||
);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!projectId || !repoId) return;
|
||||
await withLoading(() => fetchRepository(projectId, repoId));
|
||||
await refreshStatus();
|
||||
}, [projectId, repoId, withLoading, refreshStatus]);
|
||||
|
||||
const checkout = useCallback(
|
||||
async (branch: string) => {
|
||||
if (!projectId || !repoId) return;
|
||||
await withLoading(() => checkoutBranch(projectId, repoId, branch));
|
||||
await refreshStatus();
|
||||
await refreshBranches();
|
||||
},
|
||||
[projectId, repoId, withLoading, refreshStatus, refreshBranches],
|
||||
);
|
||||
|
||||
const createBranchFn = useCallback(
|
||||
async (name: string, baseBranch = "HEAD") => {
|
||||
if (!projectId || !repoId) return;
|
||||
await withLoading(() =>
|
||||
createBranch(projectId, repoId, name, baseBranch),
|
||||
);
|
||||
await refreshBranches();
|
||||
await refreshStatus();
|
||||
},
|
||||
[projectId, repoId, withLoading, refreshBranches, refreshStatus],
|
||||
);
|
||||
|
||||
const deleteBranchFn = useCallback(
|
||||
async (name: string, force = false) => {
|
||||
if (!projectId || !repoId) return;
|
||||
await withLoading(() => deleteBranch(projectId, repoId, name, force));
|
||||
await refreshBranches();
|
||||
},
|
||||
[projectId, repoId, withLoading, refreshBranches],
|
||||
);
|
||||
|
||||
const merge = useCallback(
|
||||
async (sourceBranch: string, targetBranch?: string, message?: string) => {
|
||||
if (!projectId || !repoId) return;
|
||||
await withLoading(() =>
|
||||
mergeBranches(projectId, repoId, sourceBranch, targetBranch, message),
|
||||
);
|
||||
await refreshStatus();
|
||||
await refreshHistory();
|
||||
},
|
||||
[projectId, repoId, withLoading, refreshStatus, refreshHistory],
|
||||
);
|
||||
|
||||
// Auto-refresh branches when projectId/repoId become valid
|
||||
useEffect(() => {
|
||||
if (projectId && repoId) {
|
||||
void refreshBranches();
|
||||
} else {
|
||||
setBranches([]);
|
||||
setDefaultBranch("");
|
||||
}
|
||||
}, [projectId, repoId, refreshBranches]);
|
||||
|
||||
return {
|
||||
branches,
|
||||
defaultBranch,
|
||||
status,
|
||||
history,
|
||||
commitDetail,
|
||||
loading,
|
||||
error,
|
||||
refreshBranches,
|
||||
refreshStatus,
|
||||
refreshHistory,
|
||||
loadCommitDetail,
|
||||
commit,
|
||||
push,
|
||||
pull,
|
||||
fetch,
|
||||
checkout,
|
||||
createBranch: createBranchFn,
|
||||
deleteBranch: deleteBranchFn,
|
||||
merge,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
}
|
||||
@@ -40,10 +40,10 @@ export function useInstanceActions(
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||
window.location.href = `/instances/${session.id}/terminal`;
|
||||
window.open(`/instances/${session.id}/terminal`, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
window.location.href = `/projects/${session.project_id}`;
|
||||
window.open(`/projects/${session.project_id}`, "_blank", "noopener,noreferrer");
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Shared hook for starting a tool on a workspace. */
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { createInstance, startInstance } from "../api/sessions";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
import type { ToolInstance } from "../api/sessions";
|
||||
|
||||
export interface UseStartToolResult {
|
||||
starting: boolean;
|
||||
error: string | null;
|
||||
startTool: (
|
||||
workspace: Workspace,
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
configProfileId?: string,
|
||||
) => Promise<ToolInstance | null>;
|
||||
}
|
||||
|
||||
export function useStartTool(): UseStartToolResult {
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const startTool = useCallback(
|
||||
async (
|
||||
workspace: Workspace,
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
configProfileId?: string,
|
||||
): Promise<ToolInstance | null> => {
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
workspace.project_id,
|
||||
workspace.repo_id,
|
||||
toolTypeId,
|
||||
displayName || workspace.name,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
configProfileId,
|
||||
[],
|
||||
workspace.id,
|
||||
);
|
||||
await startInstance(
|
||||
workspace.project_id,
|
||||
workspace.repo_id,
|
||||
instance.id,
|
||||
configProfileId,
|
||||
);
|
||||
return instance;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to start tool";
|
||||
setError(msg);
|
||||
return null;
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { starting, error, startTool };
|
||||
}
|
||||
@@ -2,145 +2,145 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
createWorkspace,
|
||||
deleteWorkspace,
|
||||
syncWorkspace,
|
||||
updateWorkspace,
|
||||
createWorkspace,
|
||||
deleteWorkspace,
|
||||
syncWorkspace,
|
||||
updateWorkspace,
|
||||
} from "../api/workspaces";
|
||||
import type { Workspace, CreateWorkspaceRequest } from "../types/workspace";
|
||||
|
||||
export interface UseWorkspaceActionsResult {
|
||||
loadingId: string | null;
|
||||
create: (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
data: CreateWorkspaceRequest,
|
||||
) => Promise<Workspace>;
|
||||
delete: (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: Workspace,
|
||||
onRefresh: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
sync: (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: Workspace,
|
||||
onRefresh: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
update: (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
data: Partial<CreateWorkspaceRequest>,
|
||||
) => Promise<Workspace>;
|
||||
loadingId: string | null;
|
||||
create: (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
data: CreateWorkspaceRequest,
|
||||
) => Promise<Workspace>;
|
||||
delete: (
|
||||
workspace: Workspace,
|
||||
onRefresh: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
sync: (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: Workspace,
|
||||
onRefresh: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
update: (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
data: Partial<CreateWorkspaceRequest>,
|
||||
) => Promise<Workspace>;
|
||||
}
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
detail?: {
|
||||
message?: string;
|
||||
instances?: Array<{ id: string; name: string }>;
|
||||
branch_deleted?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
detail?: {
|
||||
message?: string;
|
||||
instances?: Array<{ id: string; name: string }>;
|
||||
branch_deleted?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkspaceActions(): UseWorkspaceActionsResult {
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
|
||||
const create = useCallback(
|
||||
async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => {
|
||||
return createWorkspace(projectId, repoId, data);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const create = useCallback(
|
||||
async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => {
|
||||
return createWorkspace(projectId, repoId, data);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteAction = useCallback(
|
||||
async (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: Workspace,
|
||||
onRefresh: () => Promise<void>,
|
||||
) => {
|
||||
setLoadingId(workspace.id);
|
||||
try {
|
||||
await deleteWorkspace(projectId, repoId, workspace.id);
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const error = err as ApiError;
|
||||
if (error.response?.status === 409) {
|
||||
const detail = error.response.data?.detail;
|
||||
const instances = detail?.instances || [];
|
||||
const confirmed = window.confirm(
|
||||
`This workspace has ${instances.length} running tool instance(s):\n` +
|
||||
instances.map((i) => `- ${i.name}`).join("\n") +
|
||||
`\n\nDelete workspace and all instances?`,
|
||||
);
|
||||
if (confirmed) {
|
||||
await deleteWorkspace(projectId, repoId, workspace.id, true);
|
||||
await onRefresh();
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const deleteAction = useCallback(
|
||||
async (workspace: Workspace, onRefresh: () => Promise<void>) => {
|
||||
setLoadingId(workspace.id);
|
||||
try {
|
||||
await deleteWorkspace(workspace.id);
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const error = err as ApiError;
|
||||
if (error.response?.status === 409) {
|
||||
const detail = error.response.data?.detail;
|
||||
const instances = detail?.instances || [];
|
||||
const confirmed = window.confirm(
|
||||
`This workspace has ${instances.length} running tool instance(s):\n` +
|
||||
instances.map((i) => `- ${i.name}`).join("\n") +
|
||||
`\n\nDelete workspace and all instances?`,
|
||||
);
|
||||
if (confirmed) {
|
||||
await deleteWorkspace(workspace.id, true);
|
||||
await onRefresh();
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const sync = useCallback(
|
||||
async (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: Workspace,
|
||||
onRefresh: () => Promise<void>,
|
||||
) => {
|
||||
setLoadingId(workspace.id);
|
||||
try {
|
||||
await syncWorkspace(projectId, repoId, workspace.id);
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const error = err as ApiError;
|
||||
if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
|
||||
const message = error.response.data.detail.message || "Branch was deleted from remote";
|
||||
const confirmed = window.confirm(`${message}\n\nDelete this workspace?`);
|
||||
if (confirmed) {
|
||||
await deleteWorkspace(projectId, repoId, workspace.id, true);
|
||||
await onRefresh();
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const sync = useCallback(
|
||||
async (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: Workspace,
|
||||
onRefresh: () => Promise<void>,
|
||||
) => {
|
||||
setLoadingId(workspace.id);
|
||||
try {
|
||||
await syncWorkspace(projectId, repoId, workspace.id);
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const error = err as ApiError;
|
||||
if (
|
||||
error.response?.status === 409 &&
|
||||
error.response.data?.detail?.branch_deleted
|
||||
) {
|
||||
const message =
|
||||
error.response.data.detail.message ||
|
||||
"Branch was deleted from remote";
|
||||
const confirmed = window.confirm(
|
||||
`${message}\n\nDelete this workspace?`,
|
||||
);
|
||||
if (confirmed) {
|
||||
await deleteWorkspace(workspace.id, true);
|
||||
await onRefresh();
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
data: Partial<CreateWorkspaceRequest>,
|
||||
) => {
|
||||
return updateWorkspace(projectId, repoId, workspaceId, data);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const update = useCallback(
|
||||
async (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
data: Partial<CreateWorkspaceRequest>,
|
||||
) => {
|
||||
return updateWorkspace(projectId, repoId, workspaceId, data);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
loadingId,
|
||||
create,
|
||||
delete: deleteAction,
|
||||
sync,
|
||||
update,
|
||||
};
|
||||
return {
|
||||
loadingId,
|
||||
create,
|
||||
delete: deleteAction,
|
||||
sync,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Hook for workspace file operations. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
listWorkspaceFiles,
|
||||
getWorkspaceFileContent,
|
||||
saveWorkspaceFile,
|
||||
type FileEntry,
|
||||
} from "../api/workspace-files";
|
||||
|
||||
export interface UseWorkspaceFilesResult {
|
||||
entries: FileEntry[];
|
||||
content: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
loadFile: (path: string) => Promise<void>;
|
||||
saveFile: (path: string, content: string, message?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWorkspaceFiles(
|
||||
workspaceId: string,
|
||||
): UseWorkspaceFilesResult {
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listWorkspaceFiles(workspaceId);
|
||||
setEntries(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load files");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (path: string) => {
|
||||
try {
|
||||
const data = await getWorkspaceFileContent(workspaceId, path);
|
||||
setContent(data);
|
||||
} catch (err) {
|
||||
setContent(null);
|
||||
setError(err instanceof Error ? err.message : "Failed to load file");
|
||||
}
|
||||
},
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
const saveFile = useCallback(
|
||||
async (path: string, fileContent: string, message?: string) => {
|
||||
await saveWorkspaceFile(workspaceId, path, fileContent, message);
|
||||
await refresh();
|
||||
},
|
||||
[workspaceId, refresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { entries, content, loading, error, refresh, loadFile, saveFile };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Hook for workspace git operations. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
getGitStatus,
|
||||
getGitBranches,
|
||||
gitCommit,
|
||||
gitPush,
|
||||
gitPull,
|
||||
gitFetch,
|
||||
gitCheckout,
|
||||
getGitHistory,
|
||||
type GitStatus,
|
||||
type Commit,
|
||||
} from "../api/workspace-git";
|
||||
|
||||
export interface UseWorkspaceGitResult {
|
||||
status: GitStatus | null;
|
||||
branches: string[];
|
||||
currentBranch: string;
|
||||
history: Commit[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
commit: (message: string) => Promise<void>;
|
||||
push: () => Promise<void>;
|
||||
pull: () => Promise<void>;
|
||||
fetch: () => Promise<void>;
|
||||
checkout: (branch: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWorkspaceGit(workspaceId: string): UseWorkspaceGitResult {
|
||||
const [status, setStatus] = useState<GitStatus | null>(null);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [currentBranch, setCurrentBranch] = useState("");
|
||||
const [history, setHistory] = useState<Commit[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [statusData, branchesData, historyData] = await Promise.all([
|
||||
getGitStatus(workspaceId),
|
||||
getGitBranches(workspaceId),
|
||||
getGitHistory(workspaceId),
|
||||
]);
|
||||
setStatus(statusData);
|
||||
setBranches(branchesData.branches);
|
||||
setCurrentBranch(branchesData.current_branch);
|
||||
setHistory(historyData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load git data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const commit = useCallback(
|
||||
async (message: string) => {
|
||||
await gitCommit(workspaceId, message);
|
||||
await refresh();
|
||||
},
|
||||
[workspaceId, refresh],
|
||||
);
|
||||
|
||||
const push = useCallback(async () => {
|
||||
await gitPush(workspaceId);
|
||||
await refresh();
|
||||
}, [workspaceId, refresh]);
|
||||
|
||||
const pull = useCallback(async () => {
|
||||
await gitPull(workspaceId);
|
||||
await refresh();
|
||||
}, [workspaceId, refresh]);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
await gitFetch(workspaceId);
|
||||
await refresh();
|
||||
}, [workspaceId, refresh]);
|
||||
|
||||
const checkout = useCallback(
|
||||
async (branch: string) => {
|
||||
await gitCheckout(workspaceId, branch);
|
||||
await refresh();
|
||||
},
|
||||
[workspaceId, refresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
status,
|
||||
branches,
|
||||
currentBranch,
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
commit,
|
||||
push,
|
||||
pull,
|
||||
fetch,
|
||||
checkout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Hook for workspace instance operations. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
listWorkspaceInstances,
|
||||
createWorkspaceInstance,
|
||||
} from "../api/workspace-instances";
|
||||
import type { ToolInstance } from "../api/sessions";
|
||||
|
||||
export interface UseWorkspaceInstancesResult {
|
||||
instances: ToolInstance[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
create: (
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
configProfileId?: string,
|
||||
) => Promise<ToolInstance>;
|
||||
}
|
||||
|
||||
export function useWorkspaceInstances(
|
||||
workspaceId: string,
|
||||
): UseWorkspaceInstancesResult {
|
||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listWorkspaceInstances(workspaceId);
|
||||
setInstances(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load instances");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const create = useCallback(
|
||||
async (
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
configProfileId?: string,
|
||||
) => {
|
||||
const instance = await createWorkspaceInstance(
|
||||
workspaceId,
|
||||
toolTypeId,
|
||||
displayName,
|
||||
configProfileId,
|
||||
);
|
||||
await refresh();
|
||||
return instance;
|
||||
},
|
||||
[workspaceId, refresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { instances, loading, error, refresh, create };
|
||||
}
|
||||
@@ -1,37 +1,45 @@
|
||||
/** Hook for fetching workspaces. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { listWorkspaces } from "../api/workspaces";
|
||||
import { listAllWorkspaces, listWorkspaces } from "../api/workspaces";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
export interface UseWorkspacesResult {
|
||||
workspaces: Workspace[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
workspaces: Workspace[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult {
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export function useWorkspaces(
|
||||
projectId?: string,
|
||||
repoId?: string,
|
||||
): UseWorkspacesResult {
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listWorkspaces(projectId, repoId);
|
||||
setWorkspaces(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load workspaces");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId]);
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data =
|
||||
projectId && repoId
|
||||
? await listWorkspaces(projectId, repoId)
|
||||
: await listAllWorkspaces();
|
||||
setWorkspaces(data);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load workspaces",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { workspaces, loading, error, refresh };
|
||||
return { workspaces, loading, error, refresh };
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ResolvedProfile,
|
||||
} from "../api/config_profiles";
|
||||
import { listProjects } from "../api/projects";
|
||||
import type { Project } from "../types";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { GitMountEditor } from "../components/git-mount-editor";
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ConfigProfilesPage = () => {
|
||||
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
|
||||
@@ -1539,7 +1539,6 @@ export const ConfigProfilesPage = () => {
|
||||
onChange={(git_mounts) =>
|
||||
updateFormField("git_mounts", git_mounts)
|
||||
}
|
||||
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+223
-235
@@ -2,265 +2,253 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import { getUserSessions, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import {
|
||||
getUserSessions,
|
||||
checkInstanceHealth,
|
||||
type Session as SessionApi,
|
||||
type InstanceHealth,
|
||||
} from "../api/sessions";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
] as const;
|
||||
|
||||
type SessionView = SessionApi;
|
||||
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<
|
||||
Record<string, InstanceHealth>
|
||||
>({});
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
const loadHome = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setProjects(projectData);
|
||||
setToolTypes(toolTypeData);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
const loadHome = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [dashboard, sessionData] = await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const {
|
||||
loadingSessionId: actionBusy,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleRecreateTunnel,
|
||||
} = useInstanceActions({ onRefresh: loadHome });
|
||||
const {
|
||||
loadingSessionId: actionBusy,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleRecreateTunnel,
|
||||
} = useInstanceActions({ onRefresh: loadHome });
|
||||
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const runningSessions = safeSessions.filter(
|
||||
(s) => s.status === "running" && s.url
|
||||
);
|
||||
for (const session of runningSessions) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id
|
||||
);
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: health,
|
||||
}));
|
||||
} catch {
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: {
|
||||
healthy: false,
|
||||
container_status: "unknown",
|
||||
container_health: null,
|
||||
container_exit_code: null,
|
||||
tunnel_status: "error",
|
||||
tunnel_status_code: null,
|
||||
probe_status: "error",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const runningSessions = safeSessions.filter(
|
||||
(s) => s.status === "running" && s.url,
|
||||
);
|
||||
for (const session of runningSessions) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: health,
|
||||
}));
|
||||
} catch {
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: {
|
||||
healthy: false,
|
||||
container_status: "unknown",
|
||||
container_health: null,
|
||||
container_exit_code: null,
|
||||
tunnel_status: "error",
|
||||
tunnel_status_code: null,
|
||||
probe_status: "error",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => {
|
||||
void checkHealth();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [safeSessions]);
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => {
|
||||
void checkHealth();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [safeSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
const activeSessions = useMemo(
|
||||
() =>
|
||||
safeSessions.filter((session) =>
|
||||
["running", "building", "pending"].includes(session.status),
|
||||
),
|
||||
[safeSessions],
|
||||
);
|
||||
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
<div className="stack-sm">
|
||||
<p className="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p className="muted">
|
||||
Open sessions, available projects, and the fastest path back into
|
||||
work.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-hero-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/projects")}
|
||||
>
|
||||
New Project
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
{status === "loading" && <LoadingState message="Loading overview..." />}
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
||||
[safeSessions]
|
||||
);
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Unable to load your workspace overview."
|
||||
onRetry={() => void loadHome()}
|
||||
/>
|
||||
)}
|
||||
|
||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSelectedProject("");
|
||||
await loadHome();
|
||||
};
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessions.length
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
<div className="stack-sm">
|
||||
<p className="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||
</div>
|
||||
<div className="home-hero-actions">
|
||||
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Open sessions</p>
|
||||
<h2>
|
||||
{
|
||||
safeSessions.filter((s) =>
|
||||
[
|
||||
"running",
|
||||
"building",
|
||||
"pending",
|
||||
"starting",
|
||||
"probing",
|
||||
"unhealthy",
|
||||
].includes(s.status),
|
||||
).length
|
||||
}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={actionBusy}
|
||||
tunnelHealth={tunnelHealth}
|
||||
showGrouping={false}
|
||||
emptyMessage="No active sessions right now."
|
||||
/>
|
||||
</section>
|
||||
|
||||
{status === "loading" && <LoadingState message="Loading overview..." />}
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Workspaces</p>
|
||||
<h2>Quick access</h2>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/workspaces")}
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Use the floating button to start a tool in any workspace.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{status === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
|
||||
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessions.length
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Open sessions</p>
|
||||
<h2>{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={actionBusy}
|
||||
tunnelHealth={tunnelHealth}
|
||||
showGrouping={false}
|
||||
emptyMessage="No active sessions right now."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Available projects</p>
|
||||
<h2>{projects.length}</h2>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<EmptyState message="No projects yet." />
|
||||
) : (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
<article className="card project-card home-project-card" key={project.id}>
|
||||
<div className="stack-sm">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
|
||||
Open Workspace
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Quick create</p>
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
repositories={repositories}
|
||||
toolTypes={toolTypes}
|
||||
onProjectChange={(projectId) => setSelectedProject(projectId)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onDelete={handleDelete}
|
||||
actionBusyId={actionBusy}
|
||||
showGrouping={false}
|
||||
maxRecent={5}
|
||||
emptyMessage="No recent sessions."
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status))
|
||||
.length > 0 && (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>
|
||||
{
|
||||
safeSessions.filter((s) =>
|
||||
["stopped", "error"].includes(s.status),
|
||||
).length
|
||||
}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onDelete={handleDelete}
|
||||
actionBusyId={actionBusy}
|
||||
showGrouping={false}
|
||||
maxRecent={5}
|
||||
emptyMessage="No recent sessions."
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { HomePage as DashboardPage };
|
||||
|
||||
+407
-190
@@ -1,216 +1,433 @@
|
||||
/** Projects page with inline repositories and workspaces. */
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
listProjects,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
createProject,
|
||||
deleteProject,
|
||||
listProjects,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
} from "../api/projects";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
} from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { WorkspaceCreateForm } from "../components/workspace-create-form";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { Project } from "../types";
|
||||
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
||||
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const {
|
||||
data: projects,
|
||||
status,
|
||||
reload,
|
||||
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
|
||||
null,
|
||||
);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [expandedProject, setExpandedProject] = useState<string | null>(null);
|
||||
const [creatingWorkspace, setCreatingWorkspace] = useState<{
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
} | null>(null);
|
||||
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
|
||||
|
||||
const safeProjects = projects ?? [];
|
||||
const safeProjects = projects ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
setFormDescription("");
|
||||
setFormError(null);
|
||||
setEditingProject(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
setFormDescription("");
|
||||
setFormError(null);
|
||||
setEditingProject(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
const openEdit = (project: Project) => {
|
||||
setFormName(project.name);
|
||||
setFormDescription(project.description ?? "");
|
||||
setFormError(null);
|
||||
setEditingProject(project);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
const openEdit = (project: ProjectWithRepos) => {
|
||||
setFormName(project.name);
|
||||
setFormDescription(project.description ?? "");
|
||||
setFormError(null);
|
||||
setEditingProject(project);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingProject(null);
|
||||
setFormError(null);
|
||||
};
|
||||
const closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingProject(null);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim()) {
|
||||
setFormError("Project name is required");
|
||||
return;
|
||||
}
|
||||
if (!formName.trim()) {
|
||||
setFormError("Project name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (dialogMode === "create") {
|
||||
const input: ProjectCreateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await createProject(input);
|
||||
} else if (dialogMode === "edit" && editingProject) {
|
||||
const input: ProjectUpdateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await updateProject(editingProject.id, input);
|
||||
}
|
||||
closeDialog();
|
||||
reload();
|
||||
} catch {
|
||||
setFormError("Failed to save project");
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (dialogMode === "create") {
|
||||
const input: ProjectCreateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await createProject(input);
|
||||
} else if (dialogMode === "edit" && editingProject) {
|
||||
const input: ProjectUpdateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await updateProject(editingProject.id, input);
|
||||
}
|
||||
closeDialog();
|
||||
reload();
|
||||
} catch {
|
||||
setFormError("Failed to save project");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (projectId: string) => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteConfirmId(null);
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
const handleDelete = async (projectId: string) => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteConfirmId(null);
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||
const handleSyncWorkspace = async (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: WorkspaceSummary,
|
||||
) => {
|
||||
setWorkspaceLoading(workspace.id);
|
||||
try {
|
||||
await syncWorkspace(projectId, repoId, workspace.id);
|
||||
reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to sync workspace");
|
||||
} finally {
|
||||
setWorkspaceLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Projects</h1>
|
||||
<button className="primary-button" onClick={openCreate} type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
const handleDeleteWorkspace = async (workspace: WorkspaceSummary) => {
|
||||
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
|
||||
setWorkspaceLoading(workspace.id);
|
||||
try {
|
||||
await deleteWorkspace(workspace.id);
|
||||
reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to delete workspace");
|
||||
} finally {
|
||||
setWorkspaceLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||
|
||||
{status === "error" && <ErrorState message="Failed to load projects" onRetry={reload} />}
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Projects</h1>
|
||||
<button className="primary-button" onClick={openCreate} type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isEmpty && <EmptyState message="No projects yet. Create your first project above." />}
|
||||
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||
|
||||
{status === "ready" && safeProjects.length > 0 && (
|
||||
<div className="project-list">
|
||||
{safeProjects.map((project) => (
|
||||
<article className="card project-card" key={project.id}>
|
||||
<div className="project-info">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||
Open Workspace
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => openEdit(project)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
{deleteConfirmId === project.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleDelete(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={() => setDeleteConfirmId(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<ErrorState message="Failed to load projects" onRetry={reload} />
|
||||
)}
|
||||
|
||||
{dialogMode !== "none" && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Description
|
||||
<textarea
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
{formError && <p className="error-text">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={closeDialog} type="button">
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
{dialogMode === "create" ? (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
{isEmpty && (
|
||||
<EmptyState message="No projects yet. Create your first project above." />
|
||||
)}
|
||||
|
||||
{status === "ready" && safeProjects.length > 0 && (
|
||||
<div className="project-list">
|
||||
{safeProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
expanded={expandedProject === project.id}
|
||||
onToggle={() =>
|
||||
setExpandedProject(
|
||||
expandedProject === project.id ? null : project.id,
|
||||
)
|
||||
}
|
||||
onEdit={() => openEdit(project)}
|
||||
onDelete={() => setDeleteConfirmId(project.id)}
|
||||
deleteConfirm={deleteConfirmId === project.id}
|
||||
onConfirmDelete={() => void handleDelete(project.id)}
|
||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||
onCreateWorkspace={(repoId) =>
|
||||
setCreatingWorkspace({ projectId: project.id, repoId })
|
||||
}
|
||||
onWorkspaceAction={(repoId, workspace, action) => {
|
||||
if (action === "sync") {
|
||||
void handleSyncWorkspace(project.id, repoId, workspace);
|
||||
} else if (action === "delete") {
|
||||
void handleDeleteWorkspace(workspace);
|
||||
}
|
||||
}}
|
||||
workspaceLoading={workspaceLoading}
|
||||
showCreateForm={
|
||||
creatingWorkspace?.projectId === project.id
|
||||
? creatingWorkspace.repoId
|
||||
: null
|
||||
}
|
||||
onCancelCreate={() => setCreatingWorkspace(null)}
|
||||
onCreated={() => {
|
||||
setCreatingWorkspace(null);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogMode !== "none" && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>
|
||||
{dialogMode === "create" ? "Create Project" : "Edit Project"}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Description
|
||||
<textarea
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
{formError && <p className="error-text">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={closeDialog}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
{dialogMode === "create" ? (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─── Project Card ─── */
|
||||
|
||||
function ProjectCard({
|
||||
project,
|
||||
expanded,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirm,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onCreateWorkspace,
|
||||
onWorkspaceAction,
|
||||
workspaceLoading,
|
||||
showCreateForm,
|
||||
onCancelCreate,
|
||||
onCreated,
|
||||
}: {
|
||||
project: ProjectWithRepos;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
deleteConfirm: boolean;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onCreateWorkspace: (repoId: string) => void;
|
||||
onWorkspaceAction: (
|
||||
repoId: string,
|
||||
workspace: WorkspaceSummary,
|
||||
action: "sync" | "delete",
|
||||
) => void;
|
||||
workspaceLoading: string | null;
|
||||
onCancelCreate: () => void;
|
||||
showCreateForm: string | null;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
return (
|
||||
<article className="card project-card">
|
||||
<div className="project-info-row">
|
||||
<button
|
||||
className="project-toggle"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
|
||||
<h3>{project.name}</h3>
|
||||
{project.repositories.length > 0 && (
|
||||
<span className="repo-count">
|
||||
{project.repositories.length} repo
|
||||
{project.repositories.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="project-actions">
|
||||
<button className="ghost-button" onClick={onEdit} type="button">
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
{deleteConfirm ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={onConfirmDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={onCancelDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="project-detail">
|
||||
{project.repositories.length === 0 ? (
|
||||
<p className="muted">No repositories yet.</p>
|
||||
) : (
|
||||
<div className="repo-list">
|
||||
{project.repositories.map((repo) => (
|
||||
<div key={repo.id} className="repo-block">
|
||||
<div className="repo-header">
|
||||
<h4>{repo.name}</h4>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => onCreateWorkspace(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
</div>
|
||||
{showCreateForm === repo.id && (
|
||||
<WorkspaceCreateForm
|
||||
defaultProjectId={project.id}
|
||||
defaultRepoId={repo.id}
|
||||
onSubmit={onCreated}
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
)}
|
||||
{repo.workspaces.length === 0 ? (
|
||||
<p className="muted">No workspaces.</p>
|
||||
) : (
|
||||
<div className="workspace-grid">
|
||||
{repo.workspaces.map((ws) => (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`workspace-chip ${ws.status}`}
|
||||
>
|
||||
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
|
||||
<span className="ws-branch">
|
||||
<Icon name="branch" size="sm" /> {ws.branch}
|
||||
</span>
|
||||
{ws.instance_count > 0 && (
|
||||
<span className="ws-instances">
|
||||
{ws.instance_count} tool
|
||||
{ws.instance_count > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<div className="ws-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={workspaceLoading === ws.id}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(repo.id, ws, "sync")
|
||||
}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-text"
|
||||
disabled={workspaceLoading === ws.id}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(repo.id, ws, "delete")
|
||||
}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
+174
-227
@@ -1,17 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { listProjects } from "../api/projects";
|
||||
import type { Project } from "../types";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import {
|
||||
getUserSessions,
|
||||
type Session,
|
||||
checkInstanceHealth,
|
||||
getUserSessions,
|
||||
type Session,
|
||||
checkInstanceHealth,
|
||||
} from "../api/sessions";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||
import { getUserConfig } from "../api/settings";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { SessionCard } from "../components/session-card";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
@@ -20,235 +15,187 @@ import type { InstanceHealth } from "../api/sessions";
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const SessionsPage = () => {
|
||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
const [tunnelHealth, setTunnelHealth] = useState<
|
||||
Record<string, InstanceHealth>
|
||||
>({});
|
||||
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [sessionsData, config] = await Promise.all([
|
||||
getUserSessions(),
|
||||
getUserConfig(),
|
||||
]);
|
||||
setSessions(sessionsData);
|
||||
setLastSessionId(config.last_session_id ?? null);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [sessionsData, config] = await Promise.all([
|
||||
getUserSessions(),
|
||||
getUserConfig(),
|
||||
]);
|
||||
setSessions(sessionsData);
|
||||
setLastSessionId(config.last_session_id ?? null);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
const {
|
||||
loadingSessionId,
|
||||
dirtyDeleteSession,
|
||||
dirtyDeleteFiles,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
clearDirtyDelete,
|
||||
} = useInstanceActions({ onRefresh: loadSessions });
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadProjects();
|
||||
}, []);
|
||||
// Poll health every 30 seconds for active web-enabled instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const activeSessions = sessions.filter(
|
||||
(s) =>
|
||||
["running", "starting", "unhealthy", "probing"].includes(s.status) &&
|
||||
s.tool_type_interfaces?.includes("web"),
|
||||
);
|
||||
for (const session of activeSessions) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: health,
|
||||
}));
|
||||
} catch {
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: {
|
||||
healthy: false,
|
||||
container_status: "unknown",
|
||||
container_health: null,
|
||||
tunnel_status: "unreachable",
|
||||
tunnel_status_code: null,
|
||||
probe_status: "unknown",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
} as InstanceHealth,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadToolTypes = async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => void checkHealth(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions]);
|
||||
|
||||
const {
|
||||
loadingSessionId,
|
||||
dirtyDeleteSession,
|
||||
dirtyDeleteFiles,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
clearDirtyDelete,
|
||||
} = useInstanceActions({ onRefresh: loadSessions });
|
||||
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
|
||||
|
||||
// Poll health every 30 seconds for active web-enabled instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const activeSessions = sessions.filter(
|
||||
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
||||
&& s.tool_type_interfaces?.includes("web")
|
||||
);
|
||||
for (const session of activeSessions) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id
|
||||
);
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: health,
|
||||
}));
|
||||
} catch {
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: {
|
||||
healthy: false,
|
||||
container_status: "unknown",
|
||||
container_health: null,
|
||||
tunnel_status: "unreachable",
|
||||
tunnel_status_code: null,
|
||||
probe_status: "unknown",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
} as InstanceHealth,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className="stack sessions-page">
|
||||
<div className="page-header">
|
||||
<h1>Sessions</h1>
|
||||
</div>
|
||||
|
||||
// Check immediately and then every 30 seconds
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => void checkHealth(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions]);
|
||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Failed to load sessions"
|
||||
onRetry={() => void loadSessions()}
|
||||
/>
|
||||
)}
|
||||
|
||||
const lastSession = useMemo(
|
||||
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||
[sessions, lastSessionId]
|
||||
);
|
||||
{status === "ready" && (
|
||||
<>
|
||||
{/* Last Session */}
|
||||
{lastSession && (
|
||||
<div className="last-session-section">
|
||||
<h2>Last Session</h2>
|
||||
<SessionCard
|
||||
session={lastSession}
|
||||
onOpen={handleOpen}
|
||||
onDelete={handleDelete}
|
||||
isBusy={loadingSessionId === lastSession.id}
|
||||
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSelectedProject("");
|
||||
await loadSessions();
|
||||
};
|
||||
{/* Session List */}
|
||||
<div className="sessions-list-wrapper">
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={loadingSessionId}
|
||||
tunnelHealth={tunnelHealth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<section className="stack sessions-page">
|
||||
<div className="page-header">
|
||||
<h1>Sessions</h1>
|
||||
</div>
|
||||
{/* Create Session — use floating button */}
|
||||
<div className="create-session-section">
|
||||
<h2>Start New Tool</h2>
|
||||
<p className="muted">
|
||||
Use the floating button (bottom-right) to start a tool in any
|
||||
workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||
|
||||
{status === "error" && <ErrorState message="Failed to load sessions" onRetry={() => void loadSessions()} />}
|
||||
|
||||
{status === "ready" && (
|
||||
<>
|
||||
{/* Last Session */}
|
||||
{lastSession && (
|
||||
<div className="last-session-section">
|
||||
<h2>Last Session</h2>
|
||||
<SessionCard
|
||||
session={lastSession}
|
||||
onOpen={handleOpen}
|
||||
onDelete={handleDelete}
|
||||
isBusy={loadingSessionId === lastSession.id}
|
||||
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session List */}
|
||||
<div className="sessions-list-wrapper">
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={loadingSessionId}
|
||||
tunnelHealth={tunnelHealth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Session */}
|
||||
<div className="create-session-section">
|
||||
<h2>Create New Session</h2>
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
repositories={repositories}
|
||||
toolTypes={toolTypes}
|
||||
onProjectChange={(projectId) => {
|
||||
setSelectedProject(projectId);
|
||||
}}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dirty Delete Confirmation Modal */}
|
||||
{dirtyDeleteSession && (
|
||||
<div className="modal-overlay" onClick={clearDirtyDelete}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Uncommitted Changes</h3>
|
||||
<p>
|
||||
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has
|
||||
uncommitted changes. Deleting this session will permanently lose these
|
||||
changes.
|
||||
</p>
|
||||
<div className="changed-files-list">
|
||||
<h4>Changed files:</h4>
|
||||
<ul>
|
||||
{dirtyDeleteFiles.map((file, idx) => (
|
||||
<li key={idx}>{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={clearDirtyDelete}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
||||
type="button"
|
||||
>
|
||||
Force Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
{/* Dirty Delete Confirmation Modal */}
|
||||
{dirtyDeleteSession && (
|
||||
<div className="modal-overlay" onClick={clearDirtyDelete}>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3>Uncommitted Changes</h3>
|
||||
<p>
|
||||
The repository{" "}
|
||||
<strong>{dirtyDeleteSession.repository_name}</strong> has
|
||||
uncommitted changes. Deleting this session will permanently
|
||||
lose these changes.
|
||||
</p>
|
||||
<div className="changed-files-list">
|
||||
<h4>Changed files:</h4>
|
||||
<ul>
|
||||
{dirtyDeleteFiles.map((file, idx) => (
|
||||
<li key={idx}>{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={clearDirtyDelete}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
||||
type="button"
|
||||
>
|
||||
Force Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/** Workspace detail page — primary work surface. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useWorkspaces } from "../hooks/use-workspaces";
|
||||
import { useWorkspaceFiles } from "../hooks/use-workspace-files";
|
||||
import { useWorkspaceGit } from "../hooks/use-workspace-git";
|
||||
import { useWorkspaceInstances } from "../hooks/use-workspace-instances";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { ToolStarter } from "../components/tool-starter";
|
||||
import type { FileEntry } from "../api/workspace-files";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
type Tab = "files" | "git" | "tools" | "settings";
|
||||
|
||||
export function WorkspaceDetailPage() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("files");
|
||||
const isMobile = useMobileViewport();
|
||||
|
||||
const { workspaces, loading: wsLoading } = useWorkspaces();
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
|
||||
if (wsLoading) {
|
||||
return <div className="loading-state">Loading workspace...</div>;
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<h2>Workspace not found</h2>
|
||||
<p>The workspace you are looking for does not exist.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`workspace-detail ${isMobile ? "mobile" : ""}`}>
|
||||
<WorkspaceHeader workspace={workspace} />
|
||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||
<div className="workspace-content">
|
||||
{activeTab === "files" && <FilesTab workspaceId={workspace.id} />}
|
||||
{activeTab === "git" && <GitTab workspaceId={workspace.id} />}
|
||||
{activeTab === "tools" && <ToolsTab workspace={workspace} />}
|
||||
{activeTab === "settings" && <SettingsTab workspace={workspace} />}
|
||||
</div>
|
||||
{isMobile && <MobileTabBar active={activeTab} onChange={setActiveTab} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHeader({
|
||||
workspace,
|
||||
}: {
|
||||
workspace: {
|
||||
name: string;
|
||||
repo_name: string;
|
||||
project_name: string;
|
||||
branch: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<header className="workspace-header">
|
||||
<div className="workspace-breadcrumb">
|
||||
<span>{workspace.project_name}</span>
|
||||
<span className="sep">/</span>
|
||||
<span>{workspace.repo_name}</span>
|
||||
<span className="sep">/</span>
|
||||
<strong>{workspace.name}</strong>
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<span className="branch-badge">
|
||||
<Icon name="branch" size="sm" /> {workspace.branch}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function TabBar({
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
active: Tab;
|
||||
onChange: (t: Tab) => void;
|
||||
}) {
|
||||
const tabs: { id: Tab; label: string; icon: string }[] = [
|
||||
{ id: "files", label: "Files", icon: "folder" },
|
||||
{ id: "git", label: "Git", icon: "branch" },
|
||||
{ id: "tools", label: "Tools", icon: "terminal" },
|
||||
{ id: "settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="tab-bar" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab ${active === tab.id ? "active" : ""}`}
|
||||
onClick={() => onChange(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={active === tab.id}
|
||||
>
|
||||
<Icon
|
||||
name={tab.icon as "folder" | "branch" | "terminal" | "settings"}
|
||||
size="sm"
|
||||
/>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileTabBar({
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
active: Tab;
|
||||
onChange: (t: Tab) => void;
|
||||
}) {
|
||||
const tabs: { id: Tab; label: string; icon: string }[] = [
|
||||
{ id: "files", label: "Files", icon: "folder" },
|
||||
{ id: "git", label: "Git", icon: "branch" },
|
||||
{ id: "tools", label: "Tools", icon: "terminal" },
|
||||
{ id: "settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="mobile-tab-bar" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`mobile-tab ${active === tab.id ? "active" : ""}`}
|
||||
onClick={() => onChange(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={active === tab.id}
|
||||
>
|
||||
<Icon
|
||||
name={tab.icon as "folder" | "branch" | "terminal" | "settings"}
|
||||
/>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Files Tab ─── */
|
||||
|
||||
function FilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
const { entries, content, loadFile, saveFile, loading, error } =
|
||||
useWorkspaceFiles(workspaceId);
|
||||
const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
|
||||
const handleSelect = (entry: FileEntry) => {
|
||||
if (entry.type === "directory") return;
|
||||
setSelectedPath(entry.path);
|
||||
setIsEditing(false);
|
||||
setEditContent(null);
|
||||
loadFile(entry.path);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (content !== null) {
|
||||
setEditContent(content);
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (selectedPath && editContent !== null) {
|
||||
await saveFile(selectedPath, editContent, commitMessage || undefined);
|
||||
setIsEditing(false);
|
||||
setCommitMessage("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="files-tab">
|
||||
{status && (
|
||||
<div className="git-toolbar">
|
||||
<div className="git-toolbar-status">
|
||||
{status.modified.length > 0 && (
|
||||
<span className="status-modified">
|
||||
M {status.modified.length}
|
||||
</span>
|
||||
)}
|
||||
{status.added.length > 0 && (
|
||||
<span className="status-added">A {status.added.length}</span>
|
||||
)}
|
||||
{status.deleted.length > 0 && (
|
||||
<span className="status-deleted">D {status.deleted.length}</span>
|
||||
)}
|
||||
{status.untracked.length > 0 && (
|
||||
<span className="status-untracked">
|
||||
? {status.untracked.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="git-toolbar-actions">
|
||||
<input
|
||||
type="text"
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Commit message"
|
||||
/>
|
||||
<button
|
||||
onClick={() => commit(commitMessage)}
|
||||
disabled={!commitMessage}
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
<button onClick={push}>Push</button>
|
||||
<button onClick={pull}>Pull</button>
|
||||
<button onClick={fetch}>Fetch</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="files-split">
|
||||
<div className="file-tree">
|
||||
{loading && <p className="muted">Loading...</p>}
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
className={`tree-entry ${entry.type} ${selectedPath === entry.path ? "selected" : ""}`}
|
||||
onClick={() => handleSelect(entry)}
|
||||
type="button"
|
||||
>
|
||||
<Icon
|
||||
name={entry.type === "directory" ? "folder" : "file"}
|
||||
size="sm"
|
||||
/>
|
||||
{entry.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="file-viewer">
|
||||
{selectedPath ? (
|
||||
<>
|
||||
<div className="file-viewer-header">
|
||||
<span>{selectedPath}</span>
|
||||
{!isEditing && <button onClick={handleEdit}>Edit</button>}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<textarea
|
||||
className="file-editor"
|
||||
value={editContent || ""}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
/>
|
||||
<div className="file-editor-actions">
|
||||
<button onClick={() => setIsEditing(false)}>Cancel</button>
|
||||
<button onClick={handleSave}>Save</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<pre className="file-content">{content || "Loading..."}</pre>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">Select a file to view</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Git Tab ─── */
|
||||
|
||||
function GitTab({ workspaceId }: { workspaceId: string }) {
|
||||
const { history, branches, currentBranch, checkout, loading, error } =
|
||||
useWorkspaceGit(workspaceId);
|
||||
|
||||
return (
|
||||
<div className="git-tab">
|
||||
<div className="git-tab-header">
|
||||
<select
|
||||
value={currentBranch}
|
||||
onChange={(e) => checkout(e.target.value)}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{loading && <p className="muted">Loading history...</p>}
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
<div className="commit-history">
|
||||
{history.map((commit) => (
|
||||
<div key={commit.hash} className="commit-row">
|
||||
<span className="commit-hash">{commit.hash.slice(0, 7)}</span>
|
||||
<span className="commit-message">{commit.message}</span>
|
||||
<span className="commit-author">{commit.author}</span>
|
||||
<span className="commit-date">{commit.date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Tools Tab ─── */
|
||||
|
||||
function ToolsTab({ workspace }: { workspace: Workspace }) {
|
||||
const { instances, loading, refresh } = useWorkspaceInstances(workspace.id);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="tools-tab">
|
||||
{loading && <p className="muted">Loading instances...</p>}
|
||||
{instances.length === 0 ? (
|
||||
<div className="empty-state-card">
|
||||
<Icon name="terminal" size="lg" />
|
||||
<h3>No tools running</h3>
|
||||
<p>Start a tool to begin coding in this workspace</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
Start Tool
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="instances-grid">
|
||||
{instances.map((instance) => (
|
||||
<div
|
||||
key={instance.id}
|
||||
className={`instance-card ${instance.status}`}
|
||||
>
|
||||
<h4>{instance.display_name}</h4>
|
||||
<span className="status-badge">{instance.status}</span>
|
||||
{instance.url && (
|
||||
<a
|
||||
href={instance.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
Start Another Tool
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Start Tool</h3>
|
||||
<ToolStarter
|
||||
workspace={workspace}
|
||||
onStarted={() => {
|
||||
setShowModal(false);
|
||||
void refresh();
|
||||
}}
|
||||
onCancel={() => setShowModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Settings Tab ─── */
|
||||
|
||||
function SettingsTab({ workspace }: { workspace: Workspace }) {
|
||||
return (
|
||||
<div className="settings-tab">
|
||||
<div className="settings-section">
|
||||
<h3>Workspace Info</h3>
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" value={workspace.name} readOnly />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Branch</label>
|
||||
<input type="text" value={workspace.branch} readOnly />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Path</label>
|
||||
<input type="text" value={workspace.path} readOnly />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Status</label>
|
||||
<span className={`status-badge ${workspace.status}`}>
|
||||
{workspace.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Created</label>
|
||||
<span>{workspace.created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,114 +6,103 @@ import { useWorkspaces } from "../hooks/use-workspaces";
|
||||
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
|
||||
import { WorkspaceCard } from "../components/workspace-card";
|
||||
import { WorkspaceCreateForm } from "../components/workspace-create-form";
|
||||
import { StartToolModal } from "../components/start-tool-modal";
|
||||
import { createInstance, startInstance } from "../api/sessions";
|
||||
import { ToolStarter } from "../components/tool-starter";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
export function WorkspacesPage() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
|
||||
|
||||
// TODO: Get projectId and repoId from URL params or context
|
||||
const projectId = "default-project";
|
||||
const repoId = "default-repo";
|
||||
const { workspaces, loading, error, refresh } = useWorkspaces();
|
||||
const actions = useWorkspaceActions();
|
||||
const handleDelete = async (workspace: Workspace) => {
|
||||
await actions.delete(workspace, refresh);
|
||||
};
|
||||
|
||||
const { workspaces, loading, error, refresh } = useWorkspaces(projectId, repoId);
|
||||
const actions = useWorkspaceActions();
|
||||
const handleSync = async (workspace: Workspace) => {
|
||||
await actions.sync(
|
||||
workspace.project_id,
|
||||
workspace.repo_id,
|
||||
workspace,
|
||||
refresh,
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreate = async (data: { name: string; branch: string }) => {
|
||||
await actions.create(projectId, repoId, data);
|
||||
setShowCreate(false);
|
||||
await refresh();
|
||||
};
|
||||
return (
|
||||
<div className="page workspaces-page">
|
||||
<header className="page-header">
|
||||
<h1>Workspaces</h1>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
const handleDelete = async (workspace: Workspace) => {
|
||||
await actions.delete(projectId, repoId, workspace, refresh);
|
||||
};
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
const handleSync = async (workspace: Workspace) => {
|
||||
await actions.sync(projectId, repoId, workspace, refresh);
|
||||
};
|
||||
{showCreate && (
|
||||
<WorkspaceCreateForm
|
||||
onSubmit={async () => {
|
||||
setShowCreate(false);
|
||||
await refresh();
|
||||
}}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
const handleStartTool = async (toolTypeId: string, configProfileId?: string) => {
|
||||
if (!startWorkspace) return;
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
toolTypeId,
|
||||
`${startWorkspace.name} - ${toolTypeId}`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
configProfileId,
|
||||
[],
|
||||
startWorkspace.id
|
||||
);
|
||||
await startInstance(projectId, repoId, instance.id, configProfileId);
|
||||
setStartWorkspace(null);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to start tool");
|
||||
}
|
||||
};
|
||||
{loading && workspaces.length === 0 ? (
|
||||
<div className="loading-state">Loading workspaces...</div>
|
||||
) : workspaces.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No workspaces yet.</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Create your first workspace
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspaces-grid">
|
||||
{workspaces.map((ws) => (
|
||||
<WorkspaceCard
|
||||
key={ws.id}
|
||||
workspace={ws}
|
||||
loading={actions.loadingId === ws.id}
|
||||
onStartTool={setStartWorkspace}
|
||||
onSync={handleSync}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div className="page workspaces-page">
|
||||
<header className="page-header">
|
||||
<h1>Workspaces</h1>
|
||||
<div className="header-actions">
|
||||
<button className="btn btn-secondary" onClick={refresh} disabled={loading}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{showCreate && (
|
||||
<WorkspaceCreateForm
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading && workspaces.length === 0 ? (
|
||||
<div className="loading-state">Loading workspaces...</div>
|
||||
) : workspaces.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No workspaces yet.</p>
|
||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
<Icon name="add" size="sm" /> Create your first workspace
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspaces-grid">
|
||||
{workspaces.map((ws) => (
|
||||
<WorkspaceCard
|
||||
key={ws.id}
|
||||
workspace={ws}
|
||||
loading={actions.loadingId === ws.id}
|
||||
onStartTool={setStartWorkspace}
|
||||
onSync={handleSync}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{startWorkspace && (
|
||||
<StartToolModal
|
||||
workspace={startWorkspace}
|
||||
onClose={() => setStartWorkspace(null)}
|
||||
onStart={handleStartTool}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{startWorkspace && (
|
||||
<div className="modal-overlay" onClick={() => setStartWorkspace(null)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Start Tool</h3>
|
||||
<ToolStarter
|
||||
workspace={startWorkspace}
|
||||
onStarted={() => {
|
||||
setStartWorkspace(null);
|
||||
void refresh();
|
||||
}}
|
||||
onCancel={() => setStartWorkspace(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+54
-36
@@ -9,7 +9,6 @@ import { ProjectsPage } from "./pages/projects";
|
||||
import { GitRepositoriesPage } from "./pages/git-repositories";
|
||||
import { GitHistoryPage } from "./pages/git-history";
|
||||
import { ProjectSettingsPage } from "./pages/project-settings";
|
||||
import { RepoWorkspace } from "./pages/repo-workspace";
|
||||
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
||||
import { TerminalPage } from "./pages/terminal";
|
||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||
@@ -17,41 +16,60 @@ import { SSHKeysPage } from "./pages/ssh-keys";
|
||||
import { ConfigProfilesPage } from "./pages/config-profiles";
|
||||
import { SessionsPage } from "./pages/sessions";
|
||||
import { WorkspacesPage } from "./pages/workspaces";
|
||||
import { WorkspaceDetailPage } from "./pages/workspace-detail";
|
||||
|
||||
export const AppRouter = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginRedirectPage />} />
|
||||
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />}>
|
||||
<Route index element={<Navigate to="general" replace />} />
|
||||
<Route path="general" element={<GeneralSettingsTab />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route path="*" element={<Navigate to="general" replace />} />
|
||||
</Route>
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||
</Route>
|
||||
<Route path="/404" element={<NotFoundPage />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginRedirectPage />} />
|
||||
<Route
|
||||
path="/ssh-keys"
|
||||
element={<Navigate to="/settings/ssh-keys" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route
|
||||
path="projects/:projectId/repositories"
|
||||
element={<GitRepositoriesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="projects/:projectId/repositories/:repoId/history"
|
||||
element={<GitHistoryPage />}
|
||||
/>
|
||||
<Route
|
||||
path="projects/:projectId/settings/*"
|
||||
element={<ProjectSettingsPage />}
|
||||
/>
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />}>
|
||||
<Route index element={<Navigate to="general" replace />} />
|
||||
<Route path="general" element={<GeneralSettingsTab />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route path="*" element={<Navigate to="general" replace />} />
|
||||
</Route>
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||
<Route
|
||||
path="workspaces/:workspaceId"
|
||||
element={<WorkspaceDetailPage />}
|
||||
/>
|
||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||
<Route
|
||||
path="instances/:instanceId/terminal"
|
||||
element={<TerminalPage />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/404" element={<NotFoundPage />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4867,3 +4867,807 @@ a:active,
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Workspace Detail Page ─── */
|
||||
|
||||
.workspace-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-header-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.workspace-breadcrumb .sep {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.workspace-breadcrumb strong {
|
||||
color: var(--ink);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.branch-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--brand);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
/* Mobile Tab Bar */
|
||||
.mobile-tab-bar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
justify-content: space-around;
|
||||
padding: var(--space-2) 0;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--border);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.mobile-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-tab.active {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* Workspace Content */
|
||||
.workspace-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Files Tab */
|
||||
.files-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.git-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.git-toolbar-status {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.git-toolbar-status span {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: 6px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-modified {
|
||||
background: var(--warning-light);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-added {
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-deleted {
|
||||
background: var(--danger-light);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-untracked {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.git-toolbar-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.git-toolbar-actions input {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.files-split {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: var(--space-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.tree-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-entry:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.tree-entry.selected {
|
||||
background: var(--brand);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.file-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.file-content {
|
||||
flex: 1;
|
||||
padding: var(--space-4);
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.file-editor {
|
||||
flex: 1;
|
||||
padding: var(--space-3);
|
||||
border: none;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.file-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Git Tab */
|
||||
.git-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.git-tab-header {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.git-tab-header select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.commit-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 120px 120px;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.commit-hash {
|
||||
font-family: monospace;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.commit-author,
|
||||
.commit-date {
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* Tools Tab */
|
||||
.tools-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.empty-state-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-10);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.instances-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.instance-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.instance-card.running {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
/* Settings Tab */
|
||||
.settings-tab {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Mobile Workspace Detail */
|
||||
@media (max-width: 767px) {
|
||||
.workspace-detail.mobile .workspace-content {
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.mobile-tab-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.files-split {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.git-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace Card Link */
|
||||
.workspace-header-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
margin: -1rem -1rem 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.workspace-header-link:hover .workspace-header h4 {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* ─── Projects Page Refresh ─── */
|
||||
|
||||
.project-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
border-radius: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-toggle:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.project-toggle h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.repo-count {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.project-detail {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.repo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.repo-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.repo-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.repo-header h4 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.workspace-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.workspace-chip a {
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.workspace-chip .ws-branch {
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.workspace-chip .ws-instances {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.workspace-chip .ws-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-chip .ws-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.workspace-chip .ws-actions button:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.workspace-chip .ws-actions button.danger-text:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ─── Workspace Create Inline ─── */
|
||||
|
||||
.workspace-create-inline {
|
||||
padding: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.workspace-create-inline h3 {
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-4);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.workspace-create-form-grid .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid input,
|
||||
.workspace-create-form-grid select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid .form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* ─── Tool Starter ─── */
|
||||
.tool-starter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.tool-starter-context {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.context-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.context-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--muted);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.context-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ssh-key-status {
|
||||
padding: var(--space-2);
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ssh-key-status .success-text,
|
||||
.ssh-key-status .warning-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ─── Instance Chips ─── */
|
||||
.instance-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.instance-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.instance-chip.running {
|
||||
background: var(--success-light);
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.instance-chip.starting,
|
||||
.instance-chip.building,
|
||||
.instance-chip.probing {
|
||||
background: var(--warning-light);
|
||||
border-color: var(--warning);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.instance-chip.error,
|
||||
.instance-chip.unhealthy,
|
||||
.instance-chip.stopped {
|
||||
background: var(--danger-light);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.instance-chip a {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.instance-chip a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ─── Tool Starter Header ─── */
|
||||
.tool-starter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tool-starter-header h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── Floating Action Button ─── */
|
||||
.start-tool-fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 100;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.start-tool-fab:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.start-tool-fab:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.start-tool-modal {
|
||||
max-width: 480px;
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.start-tool-fab {
|
||||
bottom: 5rem; /* above mobile tab bar */
|
||||
right: 1rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
+35
-10
@@ -1,18 +1,43 @@
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
export type SessionPayload = {
|
||||
user: SessionUser;
|
||||
user: SessionUser;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
default_ssh_key_id: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
default_ssh_key_id: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
branch: string;
|
||||
status: string;
|
||||
instance_count: number;
|
||||
};
|
||||
|
||||
export type RepositorySummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
remote_url: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
};
|
||||
|
||||
export type ProjectWithRepos = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
default_ssh_key_id: string | null;
|
||||
repositories: RepositorySummary[];
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
/** Types for the workspace feature. */
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
repo_id: string;
|
||||
repo_name: string;
|
||||
project_name: string;
|
||||
user_id: string;
|
||||
branch: string;
|
||||
path: string;
|
||||
status: "ready" | "syncing" | "error";
|
||||
last_sync_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
instance_count: number;
|
||||
id: string;
|
||||
name: string;
|
||||
repo_id: string;
|
||||
repo_name: string;
|
||||
repo_ssh_key_id: string | null;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
user_id: string;
|
||||
branch: string;
|
||||
path: string;
|
||||
status: "ready" | "syncing" | "error";
|
||||
last_sync_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
instance_count: number;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceRequest {
|
||||
name: string;
|
||||
branch: string;
|
||||
name: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
branch_deleted: boolean;
|
||||
pulled: boolean;
|
||||
last_sync_at: string | null;
|
||||
branch_deleted: boolean;
|
||||
pulled: boolean;
|
||||
last_sync_at: string | null;
|
||||
}
|
||||
|
||||
+1
-180
@@ -1,180 +1 @@
|
||||
import {
|
||||
House,
|
||||
Folder,
|
||||
GitBranch,
|
||||
Gear,
|
||||
User,
|
||||
SignOut,
|
||||
Plus,
|
||||
PencilSimple,
|
||||
Trash,
|
||||
FloppyDisk,
|
||||
X,
|
||||
ArrowsClockwise,
|
||||
Copy,
|
||||
MagnifyingGlass,
|
||||
List,
|
||||
Check,
|
||||
Warning,
|
||||
Info,
|
||||
Spinner,
|
||||
GitCommit,
|
||||
GitMerge,
|
||||
ClockCounterClockwise,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
File,
|
||||
FileText,
|
||||
Image,
|
||||
Binary,
|
||||
Code,
|
||||
ArrowSquareOut,
|
||||
Play,
|
||||
Stop,
|
||||
Terminal,
|
||||
ArrowLeft,
|
||||
Bell,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
export type IconName =
|
||||
| "dashboard"
|
||||
| "projects"
|
||||
| "repositories"
|
||||
| "settings"
|
||||
| "profile"
|
||||
| "logout"
|
||||
| "add"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "save"
|
||||
| "cancel"
|
||||
| "refresh"
|
||||
| "copy"
|
||||
| "search"
|
||||
| "menu"
|
||||
| "close"
|
||||
| "success"
|
||||
| "error"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "loading"
|
||||
| "branch"
|
||||
| "commit"
|
||||
| "merge"
|
||||
| "history"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "fetch"
|
||||
| "file"
|
||||
| "folder"
|
||||
| "code"
|
||||
| "document"
|
||||
| "image"
|
||||
| "binary"
|
||||
| "external"
|
||||
| "play"
|
||||
| "stop"
|
||||
| "terminal"
|
||||
| "arrow-left"
|
||||
| "bell";
|
||||
|
||||
export const iconRegistry: Record<
|
||||
IconName,
|
||||
React.ComponentType<{
|
||||
size?: number | string;
|
||||
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
|
||||
}>
|
||||
> = {
|
||||
// Navigation
|
||||
dashboard: House,
|
||||
projects: Folder,
|
||||
repositories: GitBranch,
|
||||
settings: Gear,
|
||||
profile: User,
|
||||
logout: SignOut,
|
||||
|
||||
// Actions
|
||||
add: Plus,
|
||||
edit: PencilSimple,
|
||||
delete: Trash,
|
||||
save: FloppyDisk,
|
||||
cancel: X,
|
||||
refresh: ArrowsClockwise,
|
||||
copy: Copy,
|
||||
search: MagnifyingGlass,
|
||||
menu: List,
|
||||
close: X,
|
||||
|
||||
// Status
|
||||
success: Check,
|
||||
error: X,
|
||||
warning: Warning,
|
||||
info: Info,
|
||||
loading: Spinner,
|
||||
|
||||
// Git
|
||||
branch: GitBranch,
|
||||
commit: GitCommit,
|
||||
merge: GitMerge,
|
||||
history: ClockCounterClockwise,
|
||||
pull: ArrowDown,
|
||||
push: ArrowUp,
|
||||
fetch: ArrowsClockwise,
|
||||
|
||||
// Files
|
||||
file: File,
|
||||
folder: Folder,
|
||||
code: Code,
|
||||
document: FileText,
|
||||
image: Image,
|
||||
binary: Binary,
|
||||
|
||||
// Instance actions
|
||||
external: ArrowSquareOut,
|
||||
play: Play,
|
||||
stop: Stop,
|
||||
terminal: Terminal,
|
||||
"arrow-left": ArrowLeft,
|
||||
bell: Bell,
|
||||
};
|
||||
|
||||
export const iconCategories = {
|
||||
navigation: [
|
||||
"dashboard",
|
||||
"projects",
|
||||
"repositories",
|
||||
"settings",
|
||||
"profile",
|
||||
"logout",
|
||||
] as IconName[],
|
||||
actions: [
|
||||
"add",
|
||||
"edit",
|
||||
"delete",
|
||||
"save",
|
||||
"cancel",
|
||||
"refresh",
|
||||
"copy",
|
||||
"search",
|
||||
"menu",
|
||||
"close",
|
||||
] as IconName[],
|
||||
status: ["success", "error", "warning", "info", "loading"] as IconName[],
|
||||
git: [
|
||||
"branch",
|
||||
"commit",
|
||||
"merge",
|
||||
"history",
|
||||
"pull",
|
||||
"push",
|
||||
"fetch",
|
||||
] as IconName[],
|
||||
files: [
|
||||
"file",
|
||||
"folder",
|
||||
"code",
|
||||
"document",
|
||||
"image",
|
||||
"binary",
|
||||
] as IconName[],
|
||||
};
|
||||
export type { IconName } from "../components/icon";
|
||||
|
||||
@@ -13,7 +13,11 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -93,6 +97,7 @@ services:
|
||||
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
|
||||
volumes:
|
||||
- /data/repos:/data/repos
|
||||
- /data/working-copies:/data/working-copies
|
||||
- /data/instances:/data/instances
|
||||
- avatar_uploads:/app/uploads
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
+11
-4
@@ -1,4 +1,4 @@
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
@@ -14,7 +14,11 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -58,6 +62,7 @@ services:
|
||||
INSTANCE_BASE_PATH: /data/instances
|
||||
volumes:
|
||||
- /data/repos:/data/repos
|
||||
- /data/working-copies:/data/working-copies
|
||||
- /data/instances:/data/instances
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -76,10 +81,12 @@ services:
|
||||
context: ./apps/web
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: http://localhost:8000
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8000}
|
||||
VITE_APP_URL: ${VITE_APP_URL:-http://localhost:3000}
|
||||
container_name: hq-web
|
||||
environment:
|
||||
VITE_API_BASE_URL: http://localhost:8000
|
||||
# These are only for documentation; Vite bakes values at build time.
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8000}
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
# Design: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Design** |
|
||||
| Based on | [Spec](spec.md) |
|
||||
| Next | Tasks |
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
apps/api/src/
|
||||
├── api/
|
||||
│ ├── workspace_files.py # NEW: GET/POST /workspaces/{id}/files
|
||||
│ ├── workspace_git.py # NEW: /workspaces/{id}/git/*
|
||||
│ ├── workspace_instances.py # NEW: /workspaces/{id}/instances
|
||||
│ └── workspaces.py # MODIFIED: add repo_id to POST, enrich responses
|
||||
├── services/
|
||||
│ ├── git_operations.py # NEW: workspace-scoped git commands
|
||||
│ └── file_service.py # NEW: workspace file operations
|
||||
└── models/
|
||||
└── workspace.py # UNCHANGED
|
||||
```
|
||||
|
||||
### Service: FileService
|
||||
|
||||
```python
|
||||
class FileService:
|
||||
"""Read/write files within a workspace directory."""
|
||||
|
||||
def list_directory(self, workspace: Workspace, path: str = "") -> list[FileEntry]:
|
||||
abs_path = os.path.join(workspace.path, path)
|
||||
entries = []
|
||||
for item in os.listdir(abs_path):
|
||||
full = os.path.join(abs_path, item)
|
||||
stat = os.lstat(full)
|
||||
entries.append(FileEntry(
|
||||
name=item,
|
||||
path=os.path.join(path, item),
|
||||
type="directory" if os.path.isdir(full) else "file",
|
||||
size=stat.st_size if os.path.isfile(full) else None,
|
||||
))
|
||||
return entries
|
||||
|
||||
def read_file(self, workspace: Workspace, path: str) -> str:
|
||||
abs_path = os.path.join(workspace.path, path)
|
||||
with open(abs_path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
def write_file(self, workspace: Workspace, path: str, content: str) -> None:
|
||||
abs_path = os.path.join(workspace.path, path)
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
with open(abs_path, "w") as f:
|
||||
f.write(content)
|
||||
```
|
||||
|
||||
### Service: GitOperations
|
||||
|
||||
```python
|
||||
class GitOperations:
|
||||
"""Git commands scoped to a workspace directory."""
|
||||
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self.cwd = workspace.path
|
||||
self.branch = workspace.branch
|
||||
|
||||
async def status(self) -> GitStatus:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "-C", self.cwd, "status", "--porcelain",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
return self._parse_status(stdout.decode())
|
||||
|
||||
async def commit(self, message: str) -> None:
|
||||
await self._run("git", "-C", self.cwd, "add", "-A")
|
||||
await self._run("git", "-C", self.cwd, "commit", "-m", message)
|
||||
|
||||
async def push(self) -> None:
|
||||
await self._run("git", "-C", self.cwd, "push", "origin", self.branch)
|
||||
|
||||
async def pull(self) -> None:
|
||||
await self._run("git", "-C", self.cwd, "pull", "origin", self.branch)
|
||||
|
||||
async def fetch(self) -> None:
|
||||
await self._run("git", "-C", self.cwd, "fetch", "origin")
|
||||
|
||||
async def checkout(self, branch: str) -> None:
|
||||
await self._run("git", "-C", self.cwd, "checkout", branch)
|
||||
|
||||
async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
|
||||
cmd = ["git", "-C", self.cwd, "log", f"--max-count={limit}", "--pretty=format:%H|%s|%an|%ad"]
|
||||
if path:
|
||||
cmd.extend(["--", path])
|
||||
stdout = await self._run_stdout(*cmd)
|
||||
return self._parse_log(stdout)
|
||||
```
|
||||
|
||||
### API: Workspace Files
|
||||
|
||||
```python
|
||||
@router.get("/{workspace_id}/files")
|
||||
async def list_files(workspace_id: uuid.UUID, path: str = ""):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
entries = FileService().list_directory(workspace, path)
|
||||
return {"entries": [e.dict() for e in entries]}
|
||||
|
||||
@router.get("/{workspace_id}/files/content")
|
||||
async def get_file_content(workspace_id: uuid.UUID, path: str):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
content = FileService().read_file(workspace, path)
|
||||
return {"content": content, "path": path}
|
||||
|
||||
@router.post("/{workspace_id}/files/content")
|
||||
async def write_file(workspace_id: uuid.UUID, data: dict):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
FileService().write_file(workspace, data["path"], data["content"])
|
||||
if data.get("message"):
|
||||
await GitOperations(workspace).commit(data["message"])
|
||||
return {"status": "saved"}
|
||||
```
|
||||
|
||||
### API: Workspace Git
|
||||
|
||||
```python
|
||||
@router.get("/{workspace_id}/git/status")
|
||||
async def git_status(workspace_id: uuid.UUID):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
return await GitOperations(workspace).status()
|
||||
|
||||
@router.post("/{workspace_id}/git/commit")
|
||||
async def git_commit(workspace_id: uuid.UUID, data: dict):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
await GitOperations(workspace).commit(data["message"])
|
||||
return {"status": "committed"}
|
||||
|
||||
@router.post("/{workspace_id}/git/push")
|
||||
async def git_push(workspace_id: uuid.UUID):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
await GitOperations(workspace).push()
|
||||
return {"status": "pushed"}
|
||||
|
||||
@router.post("/{workspace_id}/git/pull")
|
||||
async def git_pull(workspace_id: uuid.UUID):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
await GitOperations(workspace).pull()
|
||||
return {"status": "pulled"}
|
||||
|
||||
@router.post("/{workspace_id}/git/fetch")
|
||||
async def git_fetch(workspace_id: uuid.UUID):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
await GitOperations(workspace).fetch()
|
||||
return {"status": "fetched"}
|
||||
|
||||
@router.post("/{workspace_id}/git/checkout")
|
||||
async def git_checkout(workspace_id: uuid.UUID, data: dict):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
await GitOperations(workspace).checkout(data["branch"])
|
||||
workspace.branch = data["branch"]
|
||||
await session.commit()
|
||||
return {"status": "checked_out", "branch": data["branch"]}
|
||||
|
||||
@router.get("/{workspace_id}/git/history")
|
||||
async def git_history(workspace_id: uuid.UUID, path: str | None = None, limit: int = 50):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
return await GitOperations(workspace).history(path, limit)
|
||||
```
|
||||
|
||||
### API: Workspace Instances
|
||||
|
||||
```python
|
||||
@router.get("/{workspace_id}/instances")
|
||||
async def list_workspace_instances(workspace_id: uuid.UUID, session: AsyncSession):
|
||||
result = await session.execute(
|
||||
select(ToolInstance).where(ToolInstance.workspace_id == workspace_id)
|
||||
)
|
||||
return [instance_to_dict(i) for i in result.scalars().all()]
|
||||
|
||||
@router.post("/{workspace_id}/instances")
|
||||
async def create_workspace_instance(
|
||||
workspace_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
):
|
||||
workspace = await get_workspace(workspace_id)
|
||||
# Reuse existing create_instance logic but with workspace_id pre-set
|
||||
return await create_instance_internal(
|
||||
project_id=workspace.repo.project_id,
|
||||
repo_id=workspace.repo_id,
|
||||
tool_type_id=data["tool_type_id"],
|
||||
workspace_id=workspace_id,
|
||||
display_name=data.get("display_name"),
|
||||
config_profile_id=data.get("config_profile_id"),
|
||||
)
|
||||
```
|
||||
|
||||
### Modified: Projects API
|
||||
|
||||
```python
|
||||
@router.get("/")
|
||||
async def list_projects(user_id: uuid.UUID, session: AsyncSession):
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.owner_id == user_id).order_by(Project.created_at.desc())
|
||||
)
|
||||
projects = []
|
||||
for project in result.scalars().all():
|
||||
repos = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project.id)
|
||||
)
|
||||
repo_list = []
|
||||
for repo in repos.scalars().all():
|
||||
workspaces = await session.execute(
|
||||
select(Workspace).where(Workspace.repo_id == repo.id)
|
||||
)
|
||||
repo_list.append({
|
||||
"id": str(repo.id),
|
||||
"name": repo.name,
|
||||
"remote_url": repo.remote_url,
|
||||
"workspaces": [
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"branch": ws.branch,
|
||||
"status": ws.status,
|
||||
"instance_count": ...,
|
||||
}
|
||||
for ws in workspaces.scalars().all()
|
||||
],
|
||||
})
|
||||
projects.append({
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"repositories": repo_list,
|
||||
})
|
||||
return {"projects": projects}
|
||||
```
|
||||
|
||||
## Frontend Design
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
apps/web/src/
|
||||
├── pages/
|
||||
│ ├── workspace-detail.tsx # NEW: /workspaces/:id
|
||||
│ ├── projects.tsx # MODIFIED: inline repos + workspaces
|
||||
│ └── workspaces.tsx # MODIFIED: link to detail
|
||||
├── components/
|
||||
│ ├── workspace/
|
||||
│ │ ├── workspace-header.tsx # NEW: breadcrumb + actions
|
||||
│ │ ├── workspace-tabs.tsx # NEW: tab bar component
|
||||
│ │ ├── workspace-file-panel.tsx # NEW: Files tab (tree + viewer + git toolbar)
|
||||
│ │ ├── workspace-git-panel.tsx # NEW: Git tab (history + diff)
|
||||
│ │ ├── workspace-tools-panel.tsx # NEW: Tools tab (instances + spawn)
|
||||
│ │ ├── workspace-settings-panel.tsx # NEW: Settings tab
|
||||
│ │ ├── git-toolbar.tsx # NEW: collapsible git toolbar
|
||||
│ │ ├── file-tree.tsx # NEW: extracted from repo-workspace
|
||||
│ │ ├── file-viewer.tsx # NEW: extracted from repo-workspace
|
||||
│ │ └── start-tool-modal.tsx # EXISTING: move to workspace/
|
||||
│ ├── project/
|
||||
│ │ ├── project-card.tsx # NEW: card with inline repos
|
||||
│ │ ├── repo-section.tsx # NEW: expandable repo + workspaces
|
||||
│ │ ├── workspace-chip.tsx # NEW: small workspace card
|
||||
│ │ └── new-workspace-inline.tsx # NEW: inline form
|
||||
│ └── app-shell.tsx # MODIFIED: nav order
|
||||
├── hooks/
|
||||
│ ├── use-workspace-files.ts # NEW
|
||||
│ ├── use-workspace-git.ts # NEW
|
||||
│ ├── use-workspace-instances.ts # NEW
|
||||
│ └── use-projects-enriched.ts # NEW: projects with repos + workspaces
|
||||
├── api/
|
||||
│ ├── workspace-files.ts # NEW
|
||||
│ ├── workspace-git.ts # NEW
|
||||
│ ├── workspace-instances.ts # NEW
|
||||
│ └── projects.ts # MODIFIED: enriched response
|
||||
└── router.tsx # MODIFIED: routes
|
||||
```
|
||||
|
||||
### Component: WorkspaceDetailPage
|
||||
|
||||
```tsx
|
||||
export function WorkspaceDetailPage() {
|
||||
const { workspaceId } = useParams();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("files");
|
||||
const { workspace, loading } = useWorkspace(workspaceId);
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
if (!workspace) return <NotFoundPage />;
|
||||
|
||||
return (
|
||||
<div className="workspace-detail">
|
||||
<WorkspaceHeader workspace={workspace} />
|
||||
<WorkspaceTabs active={activeTab} onChange={setActiveTab} />
|
||||
<div className="workspace-content">
|
||||
{activeTab === "files" && <WorkspaceFilePanel workspace={workspace} />}
|
||||
{activeTab === "git" && <WorkspaceGitPanel workspace={workspace} />}
|
||||
{activeTab === "tools" && <WorkspaceToolsPanel workspace={workspace} />}
|
||||
{activeTab === "settings" && <WorkspaceSettingsPanel workspace={workspace} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceFilePanel
|
||||
|
||||
```tsx
|
||||
export function WorkspaceFilePanel({ workspace }: { workspace: Workspace }) {
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { entries, loading } = useWorkspaceFiles(workspace.id);
|
||||
const { content } = useWorkspaceFileContent(workspace.id, selectedPath);
|
||||
const { status } = useWorkspaceGitStatus(workspace.id);
|
||||
|
||||
return (
|
||||
<div className="file-panel">
|
||||
<GitToolbar workspace={workspace} status={status} />
|
||||
<div className="file-panel-body">
|
||||
<FileTree
|
||||
entries={entries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
gitStatus={status}
|
||||
/>
|
||||
<FileViewer
|
||||
path={selectedPath}
|
||||
content={content}
|
||||
isEditing={isEditing}
|
||||
onEdit={() => setIsEditing(true)}
|
||||
onSave={async (newContent, message) => {
|
||||
await saveWorkspaceFile(workspace.id, selectedPath, newContent, message);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: GitToolbar
|
||||
|
||||
```tsx
|
||||
export function GitToolbar({ workspace, status }: GitToolbarProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
|
||||
return (
|
||||
<div className={`git-toolbar ${expanded ? "expanded" : ""}`}>
|
||||
<div className="git-toolbar-summary">
|
||||
<span className="git-status modified">M {status.modified.length}</span>
|
||||
<span className="git-status added">A {status.added.length}</span>
|
||||
<span className="git-status deleted">D {status.deleted.length}</span>
|
||||
<button onClick={() => setExpanded(!expanded)}>Commit ▼</button>
|
||||
<button onClick={() => pushWorkspace(workspace.id)}>Push</button>
|
||||
<button onClick={() => pullWorkspace(workspace.id)}>Pull</button>
|
||||
<button onClick={() => fetchWorkspace(workspace.id)}>Fetch</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="git-toolbar-commit">
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Commit message"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
commitWorkspace(workspace.id, commitMessage);
|
||||
setCommitMessage("");
|
||||
setExpanded(false);
|
||||
}}
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceGitPanel
|
||||
|
||||
```tsx
|
||||
export function WorkspaceGitPanel({ workspace }: { workspace: Workspace }) {
|
||||
const { history, loading } = useWorkspaceGitHistory(workspace.id);
|
||||
const [selectedCommit, setSelectedCommit] = useState<Commit | null>(null);
|
||||
|
||||
return (
|
||||
<div className="git-panel">
|
||||
<div className="git-panel-header">
|
||||
<BranchSelector workspace={workspace} />
|
||||
<button>New Branch</button>
|
||||
</div>
|
||||
<div className="git-panel-body">
|
||||
<CommitHistory
|
||||
commits={history}
|
||||
selected={selectedCommit}
|
||||
onSelect={setSelectedCommit}
|
||||
/>
|
||||
{selectedCommit && (
|
||||
<CommitDetail commit={selectedCommit} workspace={workspace} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceToolsPanel
|
||||
|
||||
```tsx
|
||||
export function WorkspaceToolsPanel({ workspace }: { workspace: Workspace }) {
|
||||
const { instances, loading, refresh } = useWorkspaceInstances(workspace.id);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="tools-panel">
|
||||
{instances.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="terminal"
|
||||
title="No tools running"
|
||||
description="Start a tool to begin coding in this workspace"
|
||||
action={{ label: "Start Tool", onClick: () => setShowModal(true) }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="tools-grid">
|
||||
{instances.map((instance) => (
|
||||
<InstanceCard
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
onStop={refresh}
|
||||
onStart={refresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setShowModal(true)}>Start Another Tool</button>
|
||||
</>
|
||||
)}
|
||||
{showModal && (
|
||||
<StartToolModal
|
||||
workspace={workspace}
|
||||
onClose={() => setShowModal(false)}
|
||||
onStart={async (toolTypeId, configProfileId) => {
|
||||
await createWorkspaceInstance(workspace.id, toolTypeId, configProfileId);
|
||||
setShowModal(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: ProjectCard (refreshed)
|
||||
|
||||
```tsx
|
||||
export function ProjectCard({ project }: { project: EnrichedProject }) {
|
||||
return (
|
||||
<article className="card project-card">
|
||||
<div className="project-header">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-repos">
|
||||
{project.repositories.map((repo) => (
|
||||
<RepoSection key={repo.id} repo={repo} projectId={project.id} />
|
||||
))}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link to={`/projects/${project.id}`}>Open</Link>
|
||||
<button>Edit</button>
|
||||
<button>Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: RepoSection
|
||||
|
||||
```tsx
|
||||
export function RepoSection({ repo, projectId }: RepoSectionProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="repo-section">
|
||||
<button className="repo-header" onClick={() => setExpanded(!expanded)}>
|
||||
<Icon name={expanded ? "arrow-down" : "arrow-right"} />
|
||||
<span>{repo.name}</span>
|
||||
<span className="muted">{repo.remote_url}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="repo-workspaces">
|
||||
{repo.workspaces.map((ws) => (
|
||||
<Link key={ws.id} to={`/workspaces/${ws.id}`} className="workspace-chip">
|
||||
<span className="workspace-name">{ws.name}</span>
|
||||
<span className={`status-badge ${ws.status}`}>{ws.status}</span>
|
||||
{ws.instance_count > 0 && (
|
||||
<span className="instance-count">● {ws.instance_count}</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
{showForm ? (
|
||||
<NewWorkspaceInline
|
||||
projectId={projectId}
|
||||
repoId={repo.id}
|
||||
onCreated={() => setShowForm(false)}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
) : (
|
||||
<button className="new-workspace-btn" onClick={() => setShowForm(true)}>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Mobile Layout
|
||||
|
||||
### Bottom Tab Bar
|
||||
|
||||
```tsx
|
||||
export function MobileTabBar({ active, onChange }: MobileTabBarProps) {
|
||||
const tabs: { id: Tab; icon: IconName; label: string }[] = [
|
||||
{ id: "files", icon: "folder", label: "Files" },
|
||||
{ id: "git", icon: "branch", label: "Git" },
|
||||
{ id: "tools", icon: "terminal", label: "Tools" },
|
||||
{ id: "settings", icon: "settings", label: "Settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="mobile-tab-bar" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`mobile-tab ${active === tab.id ? "active" : ""}`}
|
||||
onClick={() => onChange(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={active === tab.id}
|
||||
>
|
||||
<Icon name={tab.icon} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Mobile Workspace Detail
|
||||
|
||||
```tsx
|
||||
export function MobileWorkspaceDetail({ workspace }: { workspace: Workspace }) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("files");
|
||||
|
||||
return (
|
||||
<div className="workspace-detail mobile">
|
||||
<WorkspaceHeader workspace={workspace} compact />
|
||||
<div className="workspace-content">
|
||||
{activeTab === "files" && <MobileFilePanel workspace={workspace} />}
|
||||
{activeTab === "git" && <MobileGitPanel workspace={workspace} />}
|
||||
{activeTab === "tools" && <MobileToolsPanel workspace={workspace} />}
|
||||
{activeTab === "settings" && <WorkspaceSettingsPanel workspace={workspace} />}
|
||||
</div>
|
||||
<MobileTabBar active={activeTab} onChange={setActiveTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Mobile Files tab**: Full-screen file tree. Tap file → opens viewer in slide-up panel.
|
||||
**Mobile Git tab**: Commit history list. Tap commit → diff in slide-up panel.
|
||||
**Mobile Tools tab**: Instance cards stacked, full width.
|
||||
**Mobile Settings tab**: Same as desktop, scrollable.
|
||||
|
||||
## Routing
|
||||
|
||||
```tsx
|
||||
// router.tsx changes
|
||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||
<Route path="workspaces/:workspaceId" element={<WorkspaceDetailPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:projectId" element={<ProjectDetailPage />} />
|
||||
// Remove old repo-workspace route
|
||||
// <Route path="projects/:projectId" element={<RepoWorkspace />} /> — DELETED
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Hooks
|
||||
|
||||
| Hook | Fetches | Polling |
|
||||
|---|---|---|
|
||||
| `useWorkspace(id)` | `GET /workspaces/{id}` | No |
|
||||
| `useWorkspaceFiles(id, path?)` | `GET /workspaces/{id}/files` | No |
|
||||
| `useWorkspaceFileContent(id, path?)` | `GET /workspaces/{id}/files/content` | No |
|
||||
| `useWorkspaceGitStatus(id)` | `GET /workspaces/{id}/git/status` | 10s when visible |
|
||||
| `useWorkspaceGitHistory(id)` | `GET /workspaces/{id}/git/history` | No |
|
||||
| `useWorkspaceInstances(id)` | `GET /workspaces/{id}/instances` | 10s when visible |
|
||||
| `useProjectsEnriched()` | `GET /projects/` | 30s |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Backend
|
||||
- Unit: FileService.list_directory, read_file, write_file
|
||||
- Unit: GitOperations.status, commit, push, pull, history
|
||||
- Integration: GET/POST /workspaces/{id}/files
|
||||
- Integration: /workspaces/{id}/git/* endpoints
|
||||
- Integration: /workspaces/{id}/instances
|
||||
- Integration: Enriched /projects/ response
|
||||
|
||||
### Frontend
|
||||
- Component: WorkspaceFilePanel renders file tree + viewer
|
||||
- Component: GitToolbar expands/collapses, commits
|
||||
- Component: WorkspaceToolsPanel shows empty state + modal
|
||||
- Component: ProjectCard renders repos + workspace chips
|
||||
- Hook: useWorkspaceGitStatus polls correctly
|
||||
- Hook: useProjectsEnriched caches correctly
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Multi-file search/replace
|
||||
- Real-time collaborative editing
|
||||
- Workspace backup/restore
|
||||
- Git merge conflict UI
|
||||
- Terminal inside workspace page
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New Files (Backend)
|
||||
- `apps/api/src/api/workspace_files.py`
|
||||
- `apps/api/src/api/workspace_git.py`
|
||||
- `apps/api/src/api/workspace_instances.py`
|
||||
- `apps/api/src/services/file_service.py`
|
||||
- `apps/api/src/services/git_operations.py`
|
||||
- `apps/api/tests/integration/test_workspace_files.py`
|
||||
- `apps/api/tests/integration/test_workspace_git.py`
|
||||
- `apps/api/tests/unit/test_file_service.py`
|
||||
- `apps/api/tests/unit/test_git_operations.py`
|
||||
|
||||
### Modified Files (Backend)
|
||||
- `apps/api/src/api/workspaces.py` (add repo_id to POST, enrich responses)
|
||||
- `apps/api/src/api/projects.py` (enriched list response)
|
||||
- `apps/api/src/main.py` (register new routers)
|
||||
|
||||
### New Files (Frontend)
|
||||
- `apps/web/src/pages/workspace-detail.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-header.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-tabs.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-file-panel.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-git-panel.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-tools-panel.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-settings-panel.tsx`
|
||||
- `apps/web/src/components/workspace/git-toolbar.tsx`
|
||||
- `apps/web/src/components/workspace/file-tree.tsx`
|
||||
- `apps/web/src/components/workspace/file-viewer.tsx`
|
||||
- `apps/web/src/components/project/project-card.tsx`
|
||||
- `apps/web/src/components/project/repo-section.tsx`
|
||||
- `apps/web/src/components/project/workspace-chip.tsx`
|
||||
- `apps/web/src/components/project/new-workspace-inline.tsx`
|
||||
- `apps/web/src/hooks/use-workspace-files.ts`
|
||||
- `apps/web/src/hooks/use-workspace-git.ts`
|
||||
- `apps/web/src/hooks/use-workspace-instances.ts`
|
||||
- `apps/web/src/hooks/use-projects-enriched.ts`
|
||||
- `apps/web/src/api/workspace-files.ts`
|
||||
- `apps/web/src/api/workspace-git.ts`
|
||||
- `apps/web/src/api/workspace-instances.ts`
|
||||
|
||||
### Modified Files (Frontend)
|
||||
- `apps/web/src/pages/projects.tsx` (full rewrite)
|
||||
- `apps/web/src/pages/workspaces.tsx` (link to detail)
|
||||
- `apps/web/src/components/app-shell.tsx` (nav order)
|
||||
- `apps/web/src/router.tsx` (routes)
|
||||
- `apps/web/src/api/projects.ts` (enriched response types)
|
||||
|
||||
### Deleted Files
|
||||
- `apps/web/src/pages/repo-workspace.tsx`
|
||||
- `apps/web/src/components/workspace-header.tsx`
|
||||
- `apps/web/src/components/git-toolbar.tsx` (old standalone version)
|
||||
- `apps/web/src/components/instance-list.tsx` (replaced by workspace-tools-panel)
|
||||
@@ -0,0 +1,184 @@
|
||||
# Proposal: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Proposal** |
|
||||
| Based on | [Working Copies Spec](../working-copies/spec.md) |
|
||||
| Next | Spec |
|
||||
|
||||
## Problem
|
||||
|
||||
The current UI has two competing "workspace" concepts:
|
||||
|
||||
1. **Old "Repository Workspace"** (`repo-workspace.tsx`): A file browser + editor + git toolbar view tied to a repository. This was the default view when opening a project. It reads files from the repo path directly and offers quick editing.
|
||||
2. **New "Workspace"** (`workspaces.tsx`): A list of persistent writable clones that tool instances mount. These are first-class entities with their own lifecycle.
|
||||
|
||||
These two concepts confuse users. The old workspace is redundant now that workspaces are persistent clones — users should work inside a workspace, not directly on the repo.
|
||||
|
||||
Additionally:
|
||||
- The Projects page only shows a list of projects with "Open Workspace" links — no visibility into repos or workspaces
|
||||
- Tool instances are spawned from the repo-workspace view, not from the workspace view
|
||||
- Mobile layout of the old workspace is cramped and not well-suited for the new paradigm
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the old "Repository Workspace" with a **Workspace-First** navigation model:
|
||||
|
||||
### New Information Architecture
|
||||
|
||||
```
|
||||
Projects
|
||||
└── Project Card (inline repos + workspaces)
|
||||
└── Repo: "my-app"
|
||||
├── Workspace: "main" → /workspaces/{id}
|
||||
├── Workspace: "feature-auth" → /workspaces/{id}
|
||||
└── [+ New Workspace]
|
||||
Workspaces
|
||||
└── All Workspaces (grid/list)
|
||||
└── Workspace Card → /workspaces/{id}
|
||||
```
|
||||
|
||||
### Workspace Detail Page (`/workspaces/:workspaceId`)
|
||||
|
||||
The workspace detail page is the primary work surface. It replaces the old repo-workspace:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ {project} / {repo} / {workspace-name} [Start Tool ▼] │
|
||||
├──────────────┬───────────────────────────────────────────────┤
|
||||
│ │ Tabs: [Files] [Git] [Tools] │
|
||||
│ File Tree ├───────────────────────────────────────────────┤
|
||||
│ (workspace │ │
|
||||
│ clone) │ {active tab content} │
|
||||
│ │ │
|
||||
│ 📁 src/ │ │
|
||||
│ 📄 README │ │
|
||||
│ │ │
|
||||
├──────────────┤ │
|
||||
│ Git Status │ │
|
||||
│ (compact) │ │
|
||||
└──────────────┴───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Panels (collapsible, IDE-style):**
|
||||
- **Files**: File tree from workspace clone path + file viewer/editor
|
||||
- **Git**: Commit panel, branch selector, push/pull/fetch actions (operating on workspace clone)
|
||||
- **Tools**: List of active tool instances on this workspace + spawn new tool
|
||||
|
||||
**Start Tool**: Inline modal (not page navigation) to spawn a tool instance on this workspace.
|
||||
|
||||
### Projects Page Refresh
|
||||
|
||||
Project cards now show:
|
||||
- Project name + description
|
||||
- Repositories (accordion/list)
|
||||
- For each repo: its workspaces as clickable chips/cards
|
||||
- "New Workspace" button per repo
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ My Project │
|
||||
│ A web application │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Repositories: │
|
||||
│ │
|
||||
│ ▼ my-app (git@github.com:...) │
|
||||
│ ┌─────────┐ ┌─────────────┐ [+ New] │
|
||||
│ │ main │ │ feature-auth│ │
|
||||
│ │ ● 2 │ │ ● 0 │ │
|
||||
│ └─────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ ▶ api-service │
|
||||
│ ┌─────────┐ [+ New] │
|
||||
│ │ main │ │
|
||||
│ └─────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Mobile Layout
|
||||
|
||||
Bottom tab bar (4 tabs):
|
||||
- **Files**: Full-screen file tree + viewer
|
||||
- **Git**: Compact commit panel + action buttons
|
||||
- **Tools**: Instance list + spawn button
|
||||
- **Menu**: Workspace switcher, settings
|
||||
|
||||
Swipe between tabs. File tree is always accessible.
|
||||
|
||||
### Deleted
|
||||
|
||||
- `pages/repo-workspace.tsx` — old repository workspace (file browser + editor on repo path)
|
||||
- Route `/projects/:projectId` → now shows project detail, not file browser
|
||||
- Old workspace header component
|
||||
- Git toolbar component (replaced by panel in workspace detail)
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- [ ] New workspace detail page (`/workspaces/:workspaceId`)
|
||||
- [ ] File browser reading from workspace clone path
|
||||
- [ ] File viewer/editor for workspace files
|
||||
- [ ] Git operations on workspace clone (status, commit, push, pull, fetch, branch)
|
||||
- [ ] Tool instance list per workspace
|
||||
- [ ] Inline tool spawn modal
|
||||
- [ ] Collapsible IDE-style panels (desktop)
|
||||
- [ ] Bottom tab bar layout (mobile)
|
||||
- [ ] Projects page refresh (inline repos + workspaces)
|
||||
- [ ] Workspace list page improvements (link to detail page)
|
||||
- [ ] Backend: file endpoints for workspace path
|
||||
- [ ] Backend: git endpoints for workspace path
|
||||
- [ ] Delete old `repo-workspace.tsx` and related components
|
||||
- [ ] Update routing
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Git history / diff view (deferred, can reuse existing page)
|
||||
- Workspace sharing between users
|
||||
- Advanced IDE features (search, multi-file edit)
|
||||
- Auto-sync on schedule
|
||||
- Terminal integration inside workspace page
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Question | Answer |
|
||||
|---|---|---|
|
||||
| 1 | Projects page → what happens on "Open"? | **A** — Show project detail with repos + workspaces inline |
|
||||
| 2 | Workspace page layout? | **C** — Collapsible panels, IDE-style |
|
||||
| 3 | File browser source? | Workspace clone path (`/data/working-copies/{repo-id}/{name}/`) |
|
||||
| 4 | Git actions scope? | Workspace clone |
|
||||
| 5 | Tool spawning? | Inline modal on workspace page |
|
||||
| 6 | Mobile layout? | Bottom tab bar (Files / Git / Tools / Menu), swipeable |
|
||||
| 7 | Old workspace fallback? | **No fallback** — delete immediately |
|
||||
| 8 | Projects page detail level? | Inline workspace cards on project page |
|
||||
|
||||
## Open Questions for Spec
|
||||
|
||||
1. Should the workspace detail page URL be `/workspaces/:id` or nested under project/repo?
|
||||
2. Should we keep the sidebar Workspaces nav entry, or rely on Projects → Workspace flow?
|
||||
3. How does "New Workspace" flow work from Projects page — inline form or navigate to create page?
|
||||
4. Should workspace detail show repo remote URL and allow switching branches?
|
||||
5. What happens when a workspace has no tool instances yet — show empty state or prompt to spawn?
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Users confused by navigation change | Keep "Workspaces" in sidebar, add breadcrumbs |
|
||||
| Large frontend refactor | Break into 3 PRs: backend endpoints, workspace detail page, projects refresh |
|
||||
| Mobile layout complexity | Prototype with CSS grid first, test on actual device |
|
||||
| Git operations on workspace path | Reuse existing git service, just change the path argument |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Old `repo-workspace.tsx` is deleted
|
||||
- [ ] `/projects/:id` shows project detail with repos and workspaces
|
||||
- [ ] `/workspaces/:id` shows workspace detail with Files, Git, Tools panels
|
||||
- [ ] File browser reads from workspace clone path
|
||||
- [ ] Git commit/push/pull work on workspace clone
|
||||
- [ ] Tool spawn modal creates instance with workspace mounted
|
||||
- [ ] Mobile layout uses bottom tabs
|
||||
- [ ] All existing tests pass (or updated)
|
||||
- [ ] ruff clean, TypeScript clean, eslint clean
|
||||
@@ -0,0 +1,366 @@
|
||||
# Spec: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Spec** |
|
||||
| Based on | [Proposal](proposal.md) |
|
||||
| Next | Design |
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the old "Repository Workspace" (direct repo file browser) with a **Workspace-First** model. The workspace detail page becomes the primary work surface. Projects page shows inline repos + workspaces. Old `repo-workspace.tsx` is deleted.
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Question | Answer |
|
||||
|---|---|---|
|
||||
| 1 | URL structure | `/workspaces/:id` (flat) |
|
||||
| 2 | Sidebar nav order | Workspaces → Projects |
|
||||
| 3 | New Workspace flow | Inline form on project page |
|
||||
| 4 | Branch switching | Dropdown in workspace header |
|
||||
| 5 | Empty tool state | "Start a tool" prompt card |
|
||||
| 6 | Mobile tabs | Files / Git / Tools / Settings |
|
||||
| 7 | Git toolbar | Collapsible top bar on Files tab |
|
||||
| 8 | Git tab content | History, diff, full commit log |
|
||||
|
||||
## User Flows
|
||||
|
||||
### Flow 1: Open a Project
|
||||
|
||||
1. User clicks "Projects" in sidebar
|
||||
2. Sees project cards with inline repositories
|
||||
3. Each repo shows its workspaces as clickable cards
|
||||
4. User clicks a workspace → navigates to `/workspaces/:id`
|
||||
|
||||
### Flow 2: Work in a Workspace
|
||||
|
||||
1. User is on `/workspaces/:id`
|
||||
2. **Files tab** (default): File tree (left) + file viewer/editor (right). Git toolbar at top.
|
||||
3. User edits a file, commits via git toolbar
|
||||
4. **Git tab**: Full history, diff view, detailed commit log
|
||||
5. **Tools tab**: See running instances, click "Start Tool" → inline modal
|
||||
6. **Settings tab**: Sync workspace, rename, delete
|
||||
|
||||
### Flow 3: Create a Workspace
|
||||
|
||||
1. User on Projects page, expands a repo
|
||||
2. Clicks "+ New Workspace" next to a repo
|
||||
3. Inline form appears: name input, branch dropdown
|
||||
4. Submits → workspace created, appears in list
|
||||
|
||||
### Flow 4: Start a Tool
|
||||
|
||||
1. User on workspace detail, Tools tab
|
||||
2. If no instances: "Start a tool on this workspace" card
|
||||
3. If instances: list of cards + "Start Another" button
|
||||
4. Click → inline modal: tool type picker, config profile (optional)
|
||||
5. Submit → instance created, appears in list with status
|
||||
|
||||
## Backend API
|
||||
|
||||
### New Endpoints (workspace-scoped)
|
||||
|
||||
All endpoints operate on the workspace clone path (`workspace.path`).
|
||||
|
||||
```
|
||||
# Files
|
||||
GET /workspaces/{workspace_id}/files?path=&branch=
|
||||
→ List directory entries
|
||||
GET /workspaces/{workspace_id}/files/content?path=&branch=
|
||||
→ Get file content
|
||||
POST /workspaces/{workspace_id}/files/content
|
||||
Body: { path, content, message }
|
||||
→ Commit file change
|
||||
|
||||
# Git
|
||||
GET /workspaces/{workspace_id}/git/status
|
||||
→ { modified, added, deleted, untracked, branch }
|
||||
GET /workspaces/{workspace_id}/git/branches
|
||||
→ { branches, default_branch, current_branch }
|
||||
POST /workspaces/{workspace_id}/git/commit
|
||||
Body: { message, files? }
|
||||
→ Commit staged changes
|
||||
POST /workspaces/{workspace_id}/git/push
|
||||
→ Push current branch
|
||||
POST /workspaces/{workspace_id}/git/pull
|
||||
→ Pull current branch
|
||||
POST /workspaces/{workspace_id}/git/fetch
|
||||
→ Fetch from origin
|
||||
POST /workspaces/{workspace_id}/git/checkout
|
||||
Body: { branch }
|
||||
→ Switch branch
|
||||
GET /workspaces/{workspace_id}/git/history
|
||||
Query: ?path=&limit=50
|
||||
→ Commit history for file or entire repo
|
||||
|
||||
# Tools (instances on this workspace)
|
||||
GET /workspaces/{workspace_id}/instances
|
||||
→ List tool instances using this workspace
|
||||
POST /workspaces/{workspace_id}/instances
|
||||
Body: { tool_type_id, display_name?, config_profile_id? }
|
||||
→ Create instance on this workspace
|
||||
```
|
||||
|
||||
### Existing Endpoints (unchanged)
|
||||
|
||||
```
|
||||
GET /workspaces/
|
||||
POST /workspaces/ (body: { repo_id, name, branch })
|
||||
DELETE /workspaces/{id}
|
||||
POST /workspaces/{id}/sync
|
||||
PATCH /workspaces/{id}
|
||||
```
|
||||
|
||||
Note: `POST /workspaces/` now accepts `repo_id` directly instead of nested under `/projects/{pid}/repositories/{rid}/workspaces`.
|
||||
|
||||
### Modified Endpoints
|
||||
|
||||
```
|
||||
GET /projects/
|
||||
→ Now includes `repositories` array with `workspaces` sub-array
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
No changes. Existing `workspaces` table is sufficient.
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
```
|
||||
/ → Dashboard (unchanged)
|
||||
/workspaces → All workspaces list (refreshed)
|
||||
/workspaces/:id → Workspace detail (NEW, replaces repo-workspace)
|
||||
/projects → Projects list (refreshed)
|
||||
/projects/:id → Project detail with repos + workspaces (NEW)
|
||||
/sessions → Sessions list (unchanged)
|
||||
/settings → Settings (unchanged)
|
||||
```
|
||||
|
||||
## UI Components
|
||||
|
||||
### WorkspaceDetailPage (`/workspaces/:id`)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Breadcrumb: Projects > {project} > {repo} > {workspace} │
|
||||
│ [Branch ▼ main] [Sync] [Start Tool] [Settings] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Tab bar: [Files] [Git] [Tools] [Settings] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ {Active Tab Content} │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Files Tab (default)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Git Toolbar (collapsible) │
|
||||
│ [Modified: 3] [Staged: 2] [Commit ▼] [Push] [Pull] [Fetch] │
|
||||
├──────────────┬───────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ File Tree │ File Viewer / Editor │
|
||||
│ (workspace │ │
|
||||
│ path) │ Breadcrumbs: src > utils > helpers.ts │
|
||||
│ │ │
|
||||
│ 📁 src/ │ [Edit] [History] │
|
||||
│ 📄 README │ │
|
||||
│ │ export function ... │
|
||||
│ │ │
|
||||
└──────────────┴───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Git Toolbar**: Collapsible bar above file content. Shows:
|
||||
- Status counters: Modified, Added, Deleted, Untracked
|
||||
- Commit button (with message input when expanded)
|
||||
- Push, Pull, Fetch buttons
|
||||
- Branch selector dropdown
|
||||
|
||||
#### Git Tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Branch: [main ▼] [New Branch] [Merge] [Compare] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Commit History │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ ● abc123 Fix auth middleware │ │
|
||||
│ │ ● def456 Add user profile page │ │
|
||||
│ │ ● 789abc Initial commit │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Show Diff] [Checkout] [Revert] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Tools Tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Active Tool Instances │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ [+ Start Tool] │
|
||||
│ │ Code Server │ │ Terminal │ │
|
||||
│ │ ● Running │ │ ● Stopped │ │
|
||||
│ │ [Open] [Stop│ │ [Start] [×] │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ ─ or ─ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ No tools running on this workspace │ │
|
||||
│ │ Start a tool to begin coding │ │
|
||||
│ │ [Start Tool] │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Settings Tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ Name: [my-feature-branch ] │
|
||||
│ Branch: main (tracks origin/main) │
|
||||
│ Path: /data/working-copies/{repo-id}/{name} │
|
||||
│ Created: 2024-01-15 │
|
||||
│ Last Sync: 2024-01-20 14:32 │
|
||||
│ │
|
||||
│ [Rename] [Sync Now] [Delete Workspace] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### ProjectsPage (`/projects`)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Projects [+ New Project] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ My Web App │ │
|
||||
│ │ A full-stack application │ │
|
||||
│ ├────────────────────────────────────────────────────┤ │
|
||||
│ │ Repositories: │ │
|
||||
│ │ │ │
|
||||
│ │ ▼ frontend (git@github.com:me/frontend.git) │ │
|
||||
│ │ ┌──────────┐ ┌─────────────┐ [+ New Workspace] │ │
|
||||
│ │ │ main │ │ feature-ui │ │ │
|
||||
│ │ │ ● 2 inst │ │ ● 0 inst │ │ │
|
||||
│ │ └──────────┘ └─────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ▶ backend (git@github.com:me/backend.git) │ │
|
||||
│ │ ┌──────────┐ [+ New Workspace] │ │
|
||||
│ │ │ main │ │ │
|
||||
│ │ │ ● 1 inst │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ [Edit Project] [Delete] │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Workspace Card**: Small card showing:
|
||||
- Name
|
||||
- Status badge (ready/syncing/error)
|
||||
- Instance count (dot + number)
|
||||
- Click navigates to `/workspaces/:id`
|
||||
|
||||
**New Workspace Button**: Inline form on click:
|
||||
```
|
||||
[Name: __________] [Branch: main ▼] [Create] [Cancel]
|
||||
```
|
||||
|
||||
### Mobile Layout
|
||||
|
||||
Bottom tab bar (4 tabs, always visible):
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ {Tab Content - full screen} │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
├────────────────────────────────────┤
|
||||
│ 📁 Files 🔀 Git 🛠 Tools ⚙ Settings│
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Files tab**: File tree full screen, tap file → viewer overlay
|
||||
**Git tab**: Commit history list, tap commit → diff overlay
|
||||
**Tools tab**: Instance cards stacked vertically
|
||||
**Settings tab**: Same as desktop settings, scrollable
|
||||
|
||||
## State & Data Flow
|
||||
|
||||
### Workspace Detail Page
|
||||
|
||||
```
|
||||
useWorkspace(workspaceId) → fetch /workspaces/{id}
|
||||
useWorkspaceFiles(workspaceId, path?, branch?) → fetch /workspaces/{id}/files
|
||||
useWorkspaceGitStatus(workspaceId) → fetch /workspaces/{id}/git/status
|
||||
useWorkspaceInstances(workspaceId) → fetch /workspaces/{id}/instances
|
||||
```
|
||||
|
||||
All hooks poll/refetch on:
|
||||
- Tab switch
|
||||
- User action (commit, push, etc.)
|
||||
- 30s background refresh
|
||||
|
||||
### Projects Page
|
||||
|
||||
```
|
||||
useProjects() → fetch /projects/
|
||||
useProjectWorkspaces(projectId) → derived from project.repositories.workspaces
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | UX |
|
||||
|---|---|
|
||||
| Workspace not found | 404 page with "Workspace not found" + link to workspaces |
|
||||
| Git operation fails | Toast with git stderr, retry button |
|
||||
| File read fails | "File not found" in viewer, check if on correct branch |
|
||||
| No tool types available | "No tools configured" + link to Tool Workshop |
|
||||
| Workspace path missing | "Workspace files not found — try syncing" |
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Tab bar: `role="tablist"`, keyboard arrow navigation
|
||||
- File tree: `role="tree"`, arrow key expansion
|
||||
- Git toolbar: All buttons have `aria-label`
|
||||
- Focus management: Modal traps focus, returns on close
|
||||
|
||||
## Performance
|
||||
|
||||
- File tree: Virtualized for repos > 1000 files
|
||||
- Git history: Paginated (50 commits per page)
|
||||
- Image files: Lazy loaded in viewer
|
||||
- Polling: 30s for instances, 10s for git status when visible
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `repo-workspace.tsx` and related components deleted
|
||||
- [ ] `/projects/:id` route shows project detail, not file browser
|
||||
- [ ] `/workspaces/:id` route shows workspace detail page
|
||||
- [ ] 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 with diff
|
||||
- [ ] Tools tab lists instances + spawn modal
|
||||
- [ ] 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 clean, eslint clean
|
||||
@@ -0,0 +1,132 @@
|
||||
# Tasks: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Tasks** |
|
||||
| Based on | [Design](design.md) |
|
||||
| Next | Apply |
|
||||
|
||||
## PR Breakdown
|
||||
|
||||
### PR-1: Backend — Workspace File, Git & Instance Endpoints
|
||||
**Scope**: All new backend endpoints for workspace-scoped operations
|
||||
**Est. lines**: ~900 backend, ~400 tests
|
||||
**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
|
||||
|
||||
### PR-2: Frontend — Workspace Detail Page
|
||||
**Scope**: Workspace detail page with 4 tabs, replaces old repo-workspace
|
||||
**Est. lines**: ~1,400 frontend, ~300 tests
|
||||
**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
|
||||
|
||||
### PR-3: Frontend — Projects Page Refresh & Routing
|
||||
**Scope**: Projects page with inline repos + workspaces, mobile layout
|
||||
**Est. lines**: ~800 frontend, ~200 tests
|
||||
**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
|
||||
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
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
PR-1 (Backend endpoints)
|
||||
→ PR-2 (Workspace detail page)
|
||||
→ PR-3 (Projects refresh + routing)
|
||||
```
|
||||
|
||||
Each PR depends on the previous. No parallel work.
|
||||
|
||||
## Verification Steps per PR
|
||||
|
||||
### PR-1
|
||||
```bash
|
||||
cd apps/api
|
||||
pytest tests/unit/test_file_service.py tests/unit/test_git_operations.py -v
|
||||
pytest tests/integration/test_workspace_files.py tests/integration/test_workspace_git.py tests/integration/test_workspace_instances.py -v
|
||||
python -m ruff check src/services/file_service.py src/services/git_operations.py src/api/workspace_*.py
|
||||
```
|
||||
|
||||
### PR-2
|
||||
```bash
|
||||
cd apps/web
|
||||
npx tsc --noEmit
|
||||
npx eslint src/pages/workspace-detail.tsx src/components/workspace/
|
||||
npm run test -- --run workspace-detail
|
||||
```
|
||||
|
||||
### PR-3
|
||||
```bash
|
||||
cd apps/web
|
||||
npx tsc --noEmit
|
||||
npx eslint src/pages/projects.tsx src/components/project/
|
||||
npm run test -- --run projects
|
||||
```
|
||||
@@ -0,0 +1,227 @@
|
||||
# Design: High-Performance Web Terminal
|
||||
|
||||
## Component Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Terminal │ │ WebSocket │ │ xterm.js │ │
|
||||
│ │ Component │──│ Client │──│ + WebGL addon │ │
|
||||
│ │ │ │ (binary) │ │ + DOM fallback │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ Flow control ack │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ WebSocket
|
||||
│ (binary frames)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ API Container │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Terminal │ │ WebSocket │ │ TerminalSession │ │
|
||||
│ │ Manager │──│ Endpoint │──│ (new) │ │
|
||||
│ │ (lifecycle) │ │ (router) │ │ - asyncio fd reader │ │
|
||||
│ └─────────────┘ └─────────────┘ │ - output batcher │ │
|
||||
│ │ - flow control │ │
|
||||
│ └─────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────┴────────┐ │
|
||||
│ │ docker exec -it │ │
|
||||
│ │ (subprocess) │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────┴────────┐ │
|
||||
│ │ Tool Container │ │
|
||||
│ │ (bash shell) │ │
|
||||
│ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File Changes
|
||||
|
||||
### New Files
|
||||
- `apps/api/src/services/terminal_session_v2.py` — New TerminalSession implementation
|
||||
|
||||
### Modified Files
|
||||
- `apps/api/src/services/terminal_session.py` — Delete (or keep as legacy, user said no legacy needed)
|
||||
- `apps/api/src/services/terminal_manager.py` — Update to use new TerminalSession
|
||||
- `apps/api/src/api/terminal.py` — Update WebSocket handler for binary frames + flow control
|
||||
- `apps/web/src/components/terminal.tsx` — Binary mode, WebGL, flow control ack
|
||||
- `apps/web/package.json` — Add `xterm-addon-webgl`
|
||||
|
||||
## TerminalSession Implementation
|
||||
|
||||
```python
|
||||
class TerminalSession:
|
||||
"""High-performance terminal session with asyncio-native I/O."""
|
||||
|
||||
BUFFER_SIZE = 10 * 1024
|
||||
IDLE_TIMEOUT = 30 * 60
|
||||
BATCH_WINDOW_MS = 2
|
||||
FLOW_CONTROL_THRESHOLD = 64 * 1024
|
||||
FLOW_CONTROL_RESUME = 32 * 1024
|
||||
|
||||
def __init__(self, session_id, instance_id, container_id, ...):
|
||||
self._master_fd: int | None = None
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
||||
self._websockets: set[WebSocket] = set()
|
||||
self._batch_buffer = bytearray()
|
||||
self._batch_timer: asyncio.TimerHandle | None = None
|
||||
self._unacknowledged_bytes = 0
|
||||
self._paused = False
|
||||
self._read_handler_set = False
|
||||
|
||||
async def start(self):
|
||||
self._master_fd, slave_fd = pty.openpty()
|
||||
self._set_terminal_size(80, 24)
|
||||
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
"docker", "exec", "-it", "-e", "TERM=xterm-256color",
|
||||
self.container_id, "bash", "-il",
|
||||
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
def _start_reading(self):
|
||||
"""Register fd with asyncio event loop for event-driven reading."""
|
||||
if self._read_handler_set or self._master_fd is None:
|
||||
return
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.add_reader(self._master_fd, self._on_fd_readable)
|
||||
self._read_handler_set = True
|
||||
|
||||
def _on_fd_readable(self):
|
||||
"""Callback when PTY fd has data available."""
|
||||
if self._master_fd is None or self._paused:
|
||||
return
|
||||
try:
|
||||
data = os.read(self._master_fd, 4096)
|
||||
if data:
|
||||
self._add_to_buffer(data)
|
||||
self._queue_output(data)
|
||||
self.last_activity = time.time()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
def _queue_output(self, data: bytes):
|
||||
"""Add to batch buffer, schedule flush."""
|
||||
self._batch_buffer.extend(data)
|
||||
self._unacknowledged_bytes += len(data)
|
||||
|
||||
if self._unacknowledged_bytes > self.FLOW_CONTROL_THRESHOLD:
|
||||
self._pause_output()
|
||||
|
||||
if self._batch_timer is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._batch_timer = loop.call_later(
|
||||
self.BATCH_WINDOW_MS / 1000, self._flush_batch
|
||||
)
|
||||
|
||||
def _flush_batch(self):
|
||||
"""Flush batched output to all WebSockets."""
|
||||
self._batch_timer = None
|
||||
if not self._batch_buffer:
|
||||
return
|
||||
|
||||
payload = bytes(self._batch_buffer)
|
||||
self._batch_buffer.clear()
|
||||
|
||||
dead = set()
|
||||
for ws in self._websockets:
|
||||
try:
|
||||
asyncio.create_task(ws.send_bytes(payload))
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
|
||||
self._websockets -= dead
|
||||
|
||||
def acknowledge_data(self, char_count: int):
|
||||
"""Client acknowledges processed bytes."""
|
||||
self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count)
|
||||
if self._paused and self._unacknowledged_bytes < self.FLOW_CONTROL_RESUME:
|
||||
self._resume_output()
|
||||
|
||||
def _pause_output(self):
|
||||
"""Pause reading from PTY."""
|
||||
if self._read_handler_set and self._master_fd is not None:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.remove_reader(self._master_fd)
|
||||
self._read_handler_set = False
|
||||
self._paused = True
|
||||
|
||||
def _resume_output(self):
|
||||
"""Resume reading from PTY."""
|
||||
self._paused = False
|
||||
self._start_reading()
|
||||
```
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### WebSocket Binary Mode
|
||||
```typescript
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer"; // Receive ArrayBuffer directly
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const data = new Uint8Array(event.data);
|
||||
termRef.current?.write(data);
|
||||
|
||||
// Flow control: acknowledge processed bytes
|
||||
ackAccumulator += data.length;
|
||||
if (ackAccumulator >= 4096) {
|
||||
ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator }));
|
||||
ackAccumulator = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### WebGL Renderer
|
||||
```typescript
|
||||
import { WebglAddon } from "xterm-addon-webgl";
|
||||
|
||||
const webglAddon = new WebglAddon();
|
||||
try {
|
||||
term.loadAddon(webglAddon);
|
||||
} catch (e) {
|
||||
console.warn("WebGL failed, using DOM renderer", e);
|
||||
}
|
||||
```
|
||||
|
||||
## WebSocket Protocol
|
||||
|
||||
### Message Types
|
||||
|
||||
**Client → Server:**
|
||||
- `{"type": "input", "data": "base64_encoded"}` — Keystrokes
|
||||
- `{"type": "resize", "cols": 80, "rows": 24}` — Resize
|
||||
- `{"type": "ack", "chars": 4096}` — Flow control acknowledgment
|
||||
- `{"type": "reset"}` — Reset session
|
||||
|
||||
**Server → Client:**
|
||||
- Binary frame: raw terminal output bytes
|
||||
- `{"type": "status", "status": "connected"}` — Connection status
|
||||
- `{"type": "ping"}` — Heartbeat (server → client)
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
- `test_terminal_session_v2.py` — Test batching, flow control, resize, reset
|
||||
- `test_terminal_manager.py` — Test session lifecycle with new session class
|
||||
|
||||
### Integration Tests
|
||||
- `test_terminal_websocket.py` — Full WebSocket round-trip
|
||||
|
||||
### Performance Tests
|
||||
- `benchmark_terminal_latency.py` — Measure input/output latency
|
||||
- `benchmark_terminal_throughput.py` — Measure max throughput
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
Since this is a full rewrite with no legacy support:
|
||||
- Keep a backup branch of the old terminal code
|
||||
- Feature flag in frontend: `?terminal=v2` to test before full rollout
|
||||
- Monitor error rates after deployment
|
||||
@@ -0,0 +1,160 @@
|
||||
# SDD Exploration: Responsive Web Terminal
|
||||
|
||||
## Status
|
||||
**Phase:** explore
|
||||
**Date:** 2026-06-02
|
||||
**Owner:** el Gentleman (parent session)
|
||||
**Scope:** Terminal I/O latency, rendering performance, connection stability
|
||||
|
||||
## Goal
|
||||
Achieve VS Code Server-level terminal responsiveness: near-local latency on keystrokes, smooth scrolling, no jank on output bursts, and instant resize reactions.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
Container shell → docker exec PTY → host PTY master fd → select.select(0.1s)
|
||||
→ Python read loop (10ms sleep fallback) → WebSocket.send_bytes()
|
||||
→ WebSocket (Blob mode) → frontend arrayBuffer decode → xterm.js.write()
|
||||
```
|
||||
|
||||
### Key Files
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `apps/web/src/components/terminal.tsx` | xterm.js, WebSocket client, FitAddon |
|
||||
| `apps/api/src/api/terminal.py` | WebSocket endpoint, auth, read/write/heartbeat loops |
|
||||
| `apps/api/src/services/terminal_session.py` | PTY creation, docker exec subprocess, I/O |
|
||||
| `apps/api/src/services/terminal_manager.py` | Session lifecycle, persistence, idle cleanup |
|
||||
|
||||
### Current Bottlenecks
|
||||
|
||||
#### 1. Blocking Read with 100ms Timeout
|
||||
```python
|
||||
# terminal_session.py:read_output()
|
||||
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||
if readable:
|
||||
data = os.read(self._master_fd, 4096)
|
||||
```
|
||||
**Problem:** `select.select` blocks up to 100ms when no data is available. With the read loop in `terminal.py` doing `asyncio.sleep(0.01)` between calls, worst-case latency from shell output to WebSocket is ~110ms.
|
||||
|
||||
**VS Code approach:** node-pty uses libuv's epoll/kqueue watchers — event-driven, no polling timeout.
|
||||
|
||||
#### 2. WebSocket Blob → arrayBuffer Conversion
|
||||
```typescript
|
||||
// terminal.tsx
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
const data = new Uint8Array(buffer);
|
||||
termRef.current?.write(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
**Problem:** Blob → arrayBuffer is async and adds microtask latency. Also forces GC pressure from transient Blobs.
|
||||
|
||||
**VS Code approach:** Uses `ws.binaryType = "arraybuffer"` — receives ArrayBuffer directly, zero-copy into Uint8Array.
|
||||
|
||||
#### 3. No asyncio-Native PTY Reading
|
||||
The PTY master fd is read with synchronous `os.read()` inside an async coroutine. This blocks the event loop thread for the duration of the read.
|
||||
|
||||
**VS Code approach:** node-pty's C++ binding hooks into libuv's event loop natively — true async I/O.
|
||||
|
||||
#### 4. Docker Exec Subprocess Overhead
|
||||
```python
|
||||
# terminal_session.py:start()
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
"docker", "exec", "-it", "-e", "TERM=xterm",
|
||||
self.container_id, "bash", "-c", shell_cmd,
|
||||
stdin=self._slave_fd, stdout=self._slave_fd, stderr=self._slave_fd,
|
||||
)
|
||||
```
|
||||
**Problem:** Spawns a new `docker exec` process on the host. Adds process startup latency and an extra process hop.
|
||||
|
||||
**Alternative:** Docker Engine API's `attach` endpoint with `logs=0&stream=1&stdin=1&stdout=1&stderr=1` — streams directly to the API container via Unix socket. No host subprocess.
|
||||
|
||||
#### 5. No Flow Control / Backpressure
|
||||
If a command dumps output faster than the WebSocket can send (e.g., `cat /dev/urandom | base64`), data piles up in:
|
||||
- The PTY kernel buffer (limited, ~4KB)
|
||||
- Python's deque circular buffer (10KB)
|
||||
- WebSocket's internal buffer (unbounded in some implementations)
|
||||
- xterm.js parser queue
|
||||
|
||||
**VS Code approach:** Implements explicit flow control — pauses the PTY when the client buffer exceeds a threshold, resumes when drained.
|
||||
|
||||
#### 6. xterm.js Renderer
|
||||
Current: DOM renderer (default).
|
||||
**VS Code approach:** Canvas renderer with WebGL addon for GPU-accelerated rendering.
|
||||
|
||||
## Measurement Baseline
|
||||
|
||||
Before optimization, we need metrics:
|
||||
|
||||
| Metric | How to Measure | Target |
|
||||
|--------|---------------|--------|
|
||||
| Input latency | Time from keypress to character appearing | < 16ms (1 frame) |
|
||||
| Output throughput | Bytes/sec for `cat /dev/zero` | > 1 MB/s |
|
||||
| Resize latency | Time from resize message to shell reacting | < 50ms |
|
||||
| Reconnection time | Time from disconnect to full replay | < 200ms |
|
||||
| Frame drops | Dropped frames during `yes` command | 0 |
|
||||
|
||||
## Improvement Directions
|
||||
|
||||
### Direction A: Low-Latency Read Loop (Quick Win)
|
||||
Replace `select.select` + `os.read` with `asyncio` native approach:
|
||||
- Use `loop.add_reader()` to register a callback when fd is readable
|
||||
- Or use `asyncio.to_thread()` with blocking `os.read` and immediate wake
|
||||
- Eliminate the 100ms timeout and 10ms sleep
|
||||
|
||||
### Direction B: WebSocket Binary Mode (Quick Win)
|
||||
Set `ws.binaryType = "arraybuffer"` on frontend, send binary frames directly.
|
||||
Eliminates Blob → arrayBuffer conversion.
|
||||
|
||||
### Direction C: Docker Engine API Attach (Medium)
|
||||
Replace `docker exec` subprocess with direct container attach via Docker SDK or HTTP API:
|
||||
```python
|
||||
from docker import DockerClient
|
||||
client = DockerClient()
|
||||
container = client.containers.get(container_id)
|
||||
socket = container.attach_socket(params={...})
|
||||
# socket is a raw TCP/Unix socket — read with asyncio
|
||||
```
|
||||
**Pros:** No subprocess overhead, direct stream to container
|
||||
**Cons:** Requires Docker SDK or raw HTTP over Unix socket; needs `docker` group permissions
|
||||
|
||||
### Direction D: Flow Control (Medium)
|
||||
Add backpressure mechanism:
|
||||
1. Measure WebSocket send buffer depth on backend
|
||||
2. Pause reading from PTY when buffer exceeds threshold (e.g., 64KB)
|
||||
3. Resume when buffer drains below threshold
|
||||
4. Frontend: measure xterm.js parser queue depth, pause via control message
|
||||
|
||||
### Direction E: WebGL Renderer (Quick Win)
|
||||
Add xterm-addon-webgl:
|
||||
```typescript
|
||||
import { WebglAddon } from 'xterm-addon-webgl';
|
||||
term.loadAddon(new WebglAddon());
|
||||
```
|
||||
**Pros:** GPU-accelerated, much faster for large output bursts
|
||||
**Cons:** Falls back to canvas/DOM if WebGL unavailable; slightly higher init time
|
||||
|
||||
### Direction F: Output Batching (Quick Win)
|
||||
Batch small writes before sending over WebSocket:
|
||||
- Collect output for 1-2ms
|
||||
- Send as single binary frame
|
||||
- Reduces WebSocket frame overhead for high-frequency small writes (e.g., progress bars)
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. **Measure baseline** with synthetic benchmarks
|
||||
2. **Implement Directions A + B + F** (low-risk, high-impact)
|
||||
3. **Evaluate Direction C** (Docker API attach) vs keeping docker exec
|
||||
4. **Add Direction D** (flow control) if throughput tests show issues
|
||||
5. **Add Direction E** (WebGL) as frontend enhancement
|
||||
|
||||
## Risks
|
||||
|
||||
- Docker API attach may not support PTY mode as cleanly as `docker exec -it`
|
||||
- WebGL addon may have compatibility issues on older GPUs
|
||||
- Flow control adds complexity; premature optimization risk
|
||||
- Changes to core I/O loop could introduce stability regressions
|
||||
@@ -0,0 +1,71 @@
|
||||
# Proposal: High-Performance Web Terminal
|
||||
|
||||
## Status
|
||||
**Phase:** proposal → spec → design → tasks → apply
|
||||
**Date:** 2026-06-02
|
||||
**Owner:** el Gentleman
|
||||
**Scope:** Terminal I/O latency, rendering performance, connection stability
|
||||
|
||||
## Problem
|
||||
|
||||
The current web terminal has noticeable latency on keystrokes, choppy scrolling, and poor performance during output bursts. Users report it feels "slow" compared to VS Code Server's terminal, which feels almost local.
|
||||
|
||||
## Goals
|
||||
|
||||
| Metric | Current | Target | How Measured |
|
||||
|--------|---------|--------|--------------|
|
||||
| Input latency (keypress → char visible) | ~110ms | < 16ms (1 frame) | `term.write()` timestamp diff |
|
||||
| Output throughput (`cat /dev/zero`) | ~200KB/s | > 1 MB/s | Bytes/sec over 5s |
|
||||
| Resize latency | ~200ms | < 50ms | Time from resize msg to shell SIGWINCH |
|
||||
| Reconnection + replay | ~2s | < 300ms | Time from WS open to first rendered char |
|
||||
| Frame drops during `yes` | Many | 0 | `requestAnimationFrame` counter |
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Changing the terminal UI/UX (chrome, controls, tabs)
|
||||
- Adding new terminal features (search, multi-cursor, etc.)
|
||||
- Changing authentication or session persistence model
|
||||
- Supporting non-Docker container runtimes
|
||||
|
||||
## Constraints
|
||||
|
||||
- Must work with existing tool instance lifecycle (docker containers)
|
||||
- Must preserve WebSocket-based architecture
|
||||
- Must preserve session persistence across reconnections
|
||||
- Must work in both development and production compose setups
|
||||
|
||||
## Solution Overview
|
||||
|
||||
Replace the blocking `select.select()` PTY read loop with asyncio-native event-driven I/O. Replace `docker exec` subprocess with Docker Engine API attach. Switch WebSocket to binary mode. Add output batching. Add WebGL renderer.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
1. **Keep `docker exec` for now** — Docker SDK attach doesn't support PTY mode as cleanly. We can optimize the subprocess approach with proper fd handling.
|
||||
2. **Use `asyncio.add_reader()`** — Native asyncio event-driven fd reading eliminates polling latency.
|
||||
3. **Binary WebSocket frames** — `ws.binaryType = "arraybuffer"` eliminates Blob conversion overhead.
|
||||
4. **WebGL renderer with DOM fallback** — GPU acceleration where available, graceful fallback.
|
||||
5. **Output batching with 2ms window** — Collect small writes before sending to reduce frame overhead.
|
||||
6. **Flow control v2** — Client acknowledges processed bytes; server pauses reads when buffer is full.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Event loop blocking**: `asyncio.add_reader()` on a PTY fd may not work on all platforms (should work on Linux)
|
||||
- **WebGL compatibility**: Some GPUs/drivers may fail WebGL context creation
|
||||
- **Docker exec subprocess**: Still adds overhead; may revisit Docker API attach in future
|
||||
- **Full rewrite**: Large change surface; thorough testing required
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Input latency < 16ms measured with synthetic benchmark
|
||||
- [ ] Output throughput > 1 MB/s measured with `cat /dev/zero`
|
||||
- [ ] Resize latency < 50ms
|
||||
- [ ] Reconnection + replay < 300ms
|
||||
- [ ] No frame drops during `yes` command
|
||||
- [ ] All existing terminal tests pass
|
||||
- [ ] WebGL renderer loads successfully on modern browsers
|
||||
- [ ] Graceful fallback to DOM renderer if WebGL fails
|
||||
- [ ] Flow control prevents memory bloat on `cat /dev/urandom | base64`
|
||||
|
||||
## Related
|
||||
|
||||
- `openspec/explorations/terminal-responsiveness.md` — Detailed bottleneck analysis
|
||||
@@ -0,0 +1,176 @@
|
||||
# Spec: High-Performance Web Terminal
|
||||
|
||||
## Overview
|
||||
|
||||
Complete rewrite of the terminal I/O pipeline for sub-frame latency and smooth rendering.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow (New)
|
||||
|
||||
```
|
||||
Container shell → docker exec PTY → host PTY master fd
|
||||
→ asyncio.add_reader() callback (event-driven, zero polling)
|
||||
→ output batcher (2ms window) → WebSocket.send_bytes()
|
||||
→ WebSocket binary frame → frontend ArrayBuffer
|
||||
→ xterm.js WebGL renderer → screen
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
#### 1. TerminalSession (backend)
|
||||
|
||||
**Responsibilities:**
|
||||
- Create PTY via `pty.openpty()`
|
||||
- Spawn `docker exec -it` with slave fd attached
|
||||
- Read from PTY master fd using `asyncio.add_reader()`
|
||||
- Batch output (2ms window) before sending to WebSocket
|
||||
- Handle flow control (pause/resume reads based on client ack)
|
||||
- Resize via `TIOCSWINSZ` + `SIGWINCH`
|
||||
|
||||
**Interface:**
|
||||
```python
|
||||
class TerminalSession:
|
||||
async def start(self) -> None
|
||||
async def read_loop(self, websocket) -> None # event-driven
|
||||
async def write_input(self, data: bytes) -> None
|
||||
async def resize(self, cols: int, rows: int) -> None
|
||||
async def reset(self) -> None
|
||||
async def close(self) -> None
|
||||
|
||||
# Flow control
|
||||
def acknowledge_data(self, char_count: int) -> None
|
||||
def pause_output(self) -> None
|
||||
def resume_output(self) -> None
|
||||
```
|
||||
|
||||
#### 2. TerminalManager (backend)
|
||||
|
||||
Unchanged responsibilities (session lifecycle, persistence, idle cleanup).
|
||||
|
||||
#### 3. WebSocket Handler (backend)
|
||||
|
||||
**Messages:**
|
||||
|
||||
| Direction | Type | Payload | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| C → S | `input` | `{"data": "base64"}` | Keystrokes / input |
|
||||
| C → S | `resize` | `{"cols": 80, "rows": 24}` | Terminal resize |
|
||||
| C → S | `ack` | `{"chars": 1024}` | Flow control ack |
|
||||
| C → S | `reset` | `{}` | Reset session |
|
||||
| S → C | `binary` | raw bytes | Terminal output |
|
||||
| S → C | `status` | `{"status": "connected"}` | Connection status |
|
||||
| S → C | `ping` | `{}` | Heartbeat |
|
||||
|
||||
**Key changes:**
|
||||
- Output is sent as **binary WebSocket frames**, not Blob
|
||||
- Flow control: server tracks unacknowledged bytes, pauses PTY reads at 64KB threshold
|
||||
|
||||
#### 4. TerminalComponent (frontend)
|
||||
|
||||
**Key changes:**
|
||||
- `ws.binaryType = "arraybuffer"` before connection
|
||||
- Binary frames written directly to xterm.js as `Uint8Array`
|
||||
- Flow control: send `ack` messages every 4096 processed bytes
|
||||
- WebGL renderer with DOM fallback
|
||||
- Batch resize messages (debounce 50ms)
|
||||
|
||||
**xterm.js config:**
|
||||
```typescript
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: currentFontSize,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: 0,
|
||||
allowTransparency: false,
|
||||
scrollback: 10000,
|
||||
// Performance options
|
||||
ignoreBracketedPasteMode: false,
|
||||
fastScrollSensitivity: 5,
|
||||
scrollSensitivity: 1,
|
||||
});
|
||||
```
|
||||
|
||||
### Flow Control Protocol
|
||||
|
||||
**Server-side buffer tracking:**
|
||||
```python
|
||||
self._unacknowledged_bytes = 0
|
||||
self._flow_control_threshold = 64 * 1024 # 64KB
|
||||
self._paused = False
|
||||
|
||||
def on_output(self, data: bytes) -> None:
|
||||
self._unacknowledged_bytes += len(data)
|
||||
if self._unacknowledged_bytes > self._flow_control_threshold:
|
||||
self.pause_output()
|
||||
|
||||
def acknowledge_data(self, char_count: int) -> None:
|
||||
self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count)
|
||||
if self._paused and self._unacknowledged_bytes < self._flow_control_threshold / 2:
|
||||
self.resume_output()
|
||||
```
|
||||
|
||||
**Client-side ack strategy:**
|
||||
- After every `term.write(data)`, accumulate processed bytes
|
||||
- Send `ack` message every 4096 bytes or 100ms
|
||||
|
||||
### Output Batching
|
||||
|
||||
**Server-side batcher:**
|
||||
```python
|
||||
self._batch_buffer = bytearray()
|
||||
self._batch_timer: asyncio.TimerHandle | None = None
|
||||
self._batch_window_ms = 2
|
||||
|
||||
def queue_output(self, data: bytes) -> None:
|
||||
self._batch_buffer.extend(data)
|
||||
if self._batch_timer is None:
|
||||
self._batch_timer = asyncio.get_event_loop().call_later(
|
||||
self._batch_window_ms / 1000, self._flush_batch
|
||||
)
|
||||
|
||||
async def _flush_batch(self) -> None:
|
||||
self._batch_timer = None
|
||||
if self._batch_buffer and websocket.open:
|
||||
await websocket.send_bytes(bytes(self._batch_buffer))
|
||||
self._batch_buffer.clear()
|
||||
```
|
||||
|
||||
## Docker Exec Subprocess
|
||||
|
||||
**Command:**
|
||||
```bash
|
||||
docker exec -it -e TERM=xterm-256color <container_id> bash -il
|
||||
```
|
||||
|
||||
**Why keep docker exec:**
|
||||
- Docker SDK `attach()` doesn't support PTY allocation cleanly
|
||||
- `docker exec -it` is the standard way to get an interactive TTY
|
||||
- Subprocess overhead is acceptable compared to PTY latency improvements
|
||||
|
||||
**Optimization:** Pre-warm the connection by reusing the same `docker exec` process for the session lifetime.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| WebGL init fails | Fall back to DOM renderer, log warning |
|
||||
| Flow control ack lost | Server resumes after timeout (5s) |
|
||||
| PTY fd closed | Close WebSocket with code 4004 |
|
||||
| Docker exec exits | Close WebSocket with code 4004, allow reconnect |
|
||||
| Binary frame too large | Split into multiple frames (max 64KB) |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests:** Mock PTY fd, verify batching, flow control, resize
|
||||
2. **Integration tests:** Full WebSocket round-trip with test container
|
||||
3. **Performance tests:**
|
||||
- `time cat /dev/zero | head -c 10M` — measure throughput
|
||||
- Rapid keypress script — measure input latency
|
||||
- Resize storm — measure resize latency
|
||||
4. **Browser tests:** WebGL fallback on devices without GPU
|
||||
|
||||
## Migration
|
||||
|
||||
Full rewrite — no migration needed. Old terminal code can be deleted.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Tasks: High-Performance Web Terminal
|
||||
|
||||
## Task 1: Rewrite TerminalSession with asyncio-native I/O
|
||||
**Status:** pending
|
||||
**Files:** `apps/api/src/services/terminal_session.py` (full rewrite)
|
||||
**Description:**
|
||||
- Replace `select.select()` with `asyncio.add_reader()` for event-driven PTY reading
|
||||
- Add output batching (2ms window)
|
||||
- Add flow control (pause/resume based on client ack)
|
||||
- Keep docker exec subprocess (optimized)
|
||||
- Remove circular buffer (not needed with event-driven architecture)
|
||||
|
||||
## Task 2: Update TerminalManager for new session class
|
||||
**Status:** pending
|
||||
**Files:** `apps/api/src/services/terminal_manager.py`
|
||||
**Description:**
|
||||
- Update imports to use rewritten TerminalSession
|
||||
- Verify session lifecycle methods still work
|
||||
- Update DB persistence calls
|
||||
|
||||
## Task 3: Update WebSocket endpoint for binary frames + flow control
|
||||
**Status:** pending
|
||||
**Files:** `apps/api/src/api/terminal.py`
|
||||
**Description:**
|
||||
- Accept binary output frames from TerminalSession
|
||||
- Handle `ack` flow control messages from client
|
||||
- Send `ping` heartbeat
|
||||
- Maintain existing auth and session management
|
||||
|
||||
## Task 4: Update frontend for binary WebSocket + WebGL
|
||||
**Status:** pending
|
||||
**Files:** `apps/web/src/components/terminal.tsx`, `apps/web/package.json`
|
||||
**Description:**
|
||||
- Set `ws.binaryType = "arraybuffer"`
|
||||
- Send flow control `ack` messages
|
||||
- Add xterm-addon-webgl with DOM fallback
|
||||
- Optimize resize handling
|
||||
|
||||
## Task 5: Add performance benchmarks
|
||||
**Status:** pending
|
||||
**Files:** `apps/api/tests/benchmark_terminal.py`
|
||||
**Description:**
|
||||
- Input latency benchmark
|
||||
- Output throughput benchmark
|
||||
- Resize latency benchmark
|
||||
- Reconnection time benchmark
|
||||
|
||||
## Task 6: Update/fix unit tests
|
||||
**Status:** pending
|
||||
**Files:** `apps/api/tests/unit/test_tool_instances_legacy.py`, new tests
|
||||
**Description:**
|
||||
- Fix any tests broken by terminal changes
|
||||
- Add tests for new TerminalSession features
|
||||
|
||||
## Task 7: Run full test suite
|
||||
**Status:** pending
|
||||
**Description:**
|
||||
- Run all API tests
|
||||
- Verify no regressions
|
||||
- Report quality gate results
|
||||
Reference in New Issue
Block a user