refactor: extract CSS modules for terminal and git components (Task 2.2)
- Create TerminalComponent.module.css with terminal-* styles - Create GitToolbar.module.css with git toolbar styles - Create CommitDialog.module.css with commit dialog styles - Create MergeDialog.module.css with merge dialog styles - Create FileEditor.module.css with file editor styles - Update all components to import their CSS modules - Remove extracted rules from styles.css (~441 lines removed) Quality gates: tsc (pass), eslint (pass), build (pass) Refs: repo-restructure Task 2.2
This commit is contained in:
+104
-1205
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,134 @@
|
|||||||
"""Docker Compose file generation and command execution."""
|
"""Docker Compose file generation and command execution."""
|
||||||
|
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.services.profile_resolver import resolve_profile
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_name(name: str) -> str:
|
||||||
|
"""Sanitize a string for use in Docker/container names."""
|
||||||
|
sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower())
|
||||||
|
sanitized = re.sub(r"-+", "-", sanitized)
|
||||||
|
return sanitized.strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_instance_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_name: str,
|
||||||
|
tool_type_name: str,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a unique instance name: project-tool-NUM."""
|
||||||
|
base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}"
|
||||||
|
base = base.strip("-") or "instance"
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%"))
|
||||||
|
)
|
||||||
|
names = result.scalars().all()
|
||||||
|
max_num = 0
|
||||||
|
for name in names:
|
||||||
|
parts = name.rsplit("-", 1)
|
||||||
|
if len(parts) == 2 and parts[0] == base and parts[1].isdigit():
|
||||||
|
max_num = max(max_num, int(parts[1]))
|
||||||
|
return f"{base}-{max_num + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _modify_compose_file(
|
||||||
|
compose_path: str,
|
||||||
|
port_override: int | None = None,
|
||||||
|
start_command: str | None = None,
|
||||||
|
working_directory: str | None = None,
|
||||||
|
extra_volumes: list[dict] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Modify compose file with runtime overrides."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
compose_file = Path(compose_path)
|
||||||
|
content = compose_file.read_text()
|
||||||
|
compose_data = yaml.safe_load(content)
|
||||||
|
|
||||||
|
if not compose_data or "services" not in compose_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
for service_name, service_config in compose_data["services"].items():
|
||||||
|
if port_override and "ports" in service_config:
|
||||||
|
for i, port_mapping in enumerate(service_config["ports"]):
|
||||||
|
if isinstance(port_mapping, str) and ":" in port_mapping:
|
||||||
|
_host_port, container_port = port_mapping.split(":", 1)
|
||||||
|
service_config["ports"][i] = f"{port_override}:{container_port}"
|
||||||
|
break
|
||||||
|
|
||||||
|
if start_command:
|
||||||
|
service_config["command"] = start_command
|
||||||
|
|
||||||
|
if working_directory:
|
||||||
|
service_config["working_dir"] = working_directory
|
||||||
|
|
||||||
|
if extra_volumes:
|
||||||
|
if "volumes" not in service_config:
|
||||||
|
service_config["volumes"] = []
|
||||||
|
for vol in extra_volumes:
|
||||||
|
source = vol.get("source", "")
|
||||||
|
target = vol.get("target", "")
|
||||||
|
vol_type = vol.get("type", "bind")
|
||||||
|
if vol_type == "bind":
|
||||||
|
service_config["volumes"].append(f"{source}:{target}")
|
||||||
|
else:
|
||||||
|
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_resolved_profile(
|
||||||
|
profile: ConfigProfile,
|
||||||
|
instance_dir: str,
|
||||||
|
env_vars: dict[str, str],
|
||||||
|
port_override: int | None,
|
||||||
|
start_command: str | None,
|
||||||
|
working_directory: str | None,
|
||||||
|
extra_volumes: list[dict],
|
||||||
|
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
|
||||||
|
"""Resolve a profile and apply its output to instance configuration."""
|
||||||
|
resolved = resolve_profile(profile)
|
||||||
|
|
||||||
|
if resolved.environment_variables:
|
||||||
|
env_vars.update(resolved.environment_variables)
|
||||||
|
|
||||||
|
if resolved.runtime_hints.start_command is not None:
|
||||||
|
start_command = resolved.runtime_hints.start_command
|
||||||
|
if resolved.runtime_hints.working_directory is not None:
|
||||||
|
working_directory = resolved.runtime_hints.working_directory
|
||||||
|
if resolved.runtime_hints.port is not None:
|
||||||
|
port_override = resolved.runtime_hints.port
|
||||||
|
|
||||||
|
for target_path, mount in resolved.mounts.items():
|
||||||
|
safe_name = target_path.strip("/").replace("/", "_")
|
||||||
|
mount_dir = Path(instance_dir) / "mounts" / safe_name
|
||||||
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for rel_path, content in mount.files.items():
|
||||||
|
file_path = mount_dir / rel_path
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
extra_volumes.append({
|
||||||
|
"source": str(mount_dir),
|
||||||
|
"target": target_path,
|
||||||
|
"type": mount.mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return env_vars, port_override, start_command, working_directory, extra_volumes
|
||||||
|
|
||||||
|
|
||||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||||
"""Render a Docker Compose template with variable substitution.
|
"""Render a Docker Compose template with variable substitution.
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
"""High-level tool instance lifecycle orchestration.
|
||||||
|
|
||||||
|
Coordinates Docker compose, container, tunnel, and config staging services
|
||||||
|
to create, start, stop, restart, and delete tool instances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_folder import ConfigFolder
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_config import ToolConfig
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
from src.services.docker import compose as compose_svc
|
||||||
|
from src.services.docker import config_staging
|
||||||
|
from src.services.docker import container as container_svc
|
||||||
|
from src.services.docker import tunnel as tunnel_svc
|
||||||
|
from src.services.docker_build import build_image
|
||||||
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_new_instance(
|
||||||
|
session: AsyncSession,
|
||||||
|
project: Project,
|
||||||
|
repo: GitRepository,
|
||||||
|
tool_type: ToolType,
|
||||||
|
user: User,
|
||||||
|
display_name: str | None,
|
||||||
|
selected_profile: ConfigProfile | None,
|
||||||
|
) -> ToolInstance:
|
||||||
|
"""Create a new tool instance record and its compose file."""
|
||||||
|
instance_name = await compose_svc._generate_instance_name(
|
||||||
|
session, project.name, tool_type.name
|
||||||
|
)
|
||||||
|
instance_dir = compose_svc.ensure_instance_directory(instance_name)
|
||||||
|
tool_port = container_svc.find_free_port()
|
||||||
|
|
||||||
|
compose_path = await _build_or_render_compose(
|
||||||
|
tool_type, instance_name, instance_dir, repo, user, project.id, tool_port
|
||||||
|
)
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
name=instance_name,
|
||||||
|
display_name=display_name or f"{tool_type.display_name} - {repo.name}",
|
||||||
|
tool_type_id=tool_type.id,
|
||||||
|
repository_id=repo.id,
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user.id,
|
||||||
|
status="pending",
|
||||||
|
compose_path=compose_path,
|
||||||
|
port=tool_port,
|
||||||
|
selected_profile_id=selected_profile.id if selected_profile else None,
|
||||||
|
)
|
||||||
|
session.add(instance)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(instance)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
async def start_existing_instance(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
user: User,
|
||||||
|
project_id: Any,
|
||||||
|
) -> dict:
|
||||||
|
"""Start an existing instance: stage configs, compose up, probe, tunnel."""
|
||||||
|
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
instance.status = "building"
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
||||||
|
session, user.id, instance.tool_type_id, project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_profile = None
|
||||||
|
if instance.selected_profile_id:
|
||||||
|
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||||
|
if selected_profile and selected_profile.user_id == user.id:
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
||||||
|
selected_profile,
|
||||||
|
instance_dir,
|
||||||
|
env_vars,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
|
)
|
||||||
|
|
||||||
|
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
||||||
|
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
||||||
|
env_vars, config_files, extra_volumes
|
||||||
|
)
|
||||||
|
|
||||||
|
if port_override or start_command or working_directory or extra_volumes:
|
||||||
|
compose_svc._modify_compose_file(
|
||||||
|
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
||||||
|
)
|
||||||
|
|
||||||
|
returncode, _stdout, stderr = compose_svc.execute_compose_command(
|
||||||
|
instance.compose_path, "up", env_file=env_file_path
|
||||||
|
)
|
||||||
|
if returncode != 0:
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"failed to start instance: {stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
container_id = container_svc.get_container_id(instance.name)
|
||||||
|
if container_id:
|
||||||
|
instance.container_id = container_id
|
||||||
|
container_name = container_svc.get_container_name(instance.name)
|
||||||
|
if container_name:
|
||||||
|
instance.container_name = container_name
|
||||||
|
container_svc.connect_container_to_network(container_name, "backend")
|
||||||
|
|
||||||
|
instance.status = "starting"
|
||||||
|
instance.last_started_at = datetime.now()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
success, probe_logs = await _run_readiness_probe(instance, tool_type)
|
||||||
|
if not success:
|
||||||
|
instance.status = "failed"
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"error": f"Readiness probe failed: {' '.join(probe_logs)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.status = "running"
|
||||||
|
await session.commit()
|
||||||
|
await _start_tunnel_if_web(instance, tool_type)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
|
|
||||||
|
async def restart_existing_instance(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
user: User,
|
||||||
|
project_id: Any,
|
||||||
|
) -> dict:
|
||||||
|
"""Restart an instance: re-stage configs, compose restart, tunnel."""
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop old tunnel: %s", exc)
|
||||||
|
|
||||||
|
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
return {"status": instance.status}
|
||||||
|
|
||||||
|
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
||||||
|
session, user.id, instance.tool_type_id, project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
stored_profile = None
|
||||||
|
if instance.selected_profile_id:
|
||||||
|
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||||
|
if stored_profile and stored_profile.user_id == user.id:
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
||||||
|
stored_profile, instance_dir, env_vars, port_override, start_command, working_directory, extra_volumes
|
||||||
|
)
|
||||||
|
|
||||||
|
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
||||||
|
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
||||||
|
env_vars, config_files, extra_volumes
|
||||||
|
)
|
||||||
|
|
||||||
|
if port_override or start_command or working_directory or extra_volumes:
|
||||||
|
compose_svc._modify_compose_file(
|
||||||
|
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
||||||
|
)
|
||||||
|
|
||||||
|
returncode, _stdout, _stderr = compose_svc.execute_compose_command(
|
||||||
|
instance.compose_path, "restart", env_file=env_file_path
|
||||||
|
)
|
||||||
|
if returncode != 0:
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
return {"status": instance.status}
|
||||||
|
|
||||||
|
instance.status = "running"
|
||||||
|
instance.last_started_at = datetime.now()
|
||||||
|
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
await _start_tunnel_if_web(instance, tool_type)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
||||||
|
"""Stop an instance and its tunnel."""
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop tunnel: %s", exc)
|
||||||
|
|
||||||
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
compose_svc.execute_compose_command(instance.compose_path, "stop")
|
||||||
|
|
||||||
|
instance.status = "stopped"
|
||||||
|
instance.last_stopped_at = datetime.now()
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
instance.tunnel_id = None
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
||||||
|
"""Delete an instance, its containers, and its directory."""
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop tunnel: %s", exc)
|
||||||
|
|
||||||
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
compose_svc.execute_compose_command(instance.compose_path, "down")
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
if os.path.exists(instance_dir):
|
||||||
|
shutil.rmtree(instance_dir)
|
||||||
|
|
||||||
|
await session.delete(instance)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Internal helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _build_or_render_compose(
|
||||||
|
tool_type: ToolType,
|
||||||
|
instance_name: str,
|
||||||
|
instance_dir: str,
|
||||||
|
repo: GitRepository,
|
||||||
|
user: User,
|
||||||
|
project_id: Any,
|
||||||
|
tool_port: int,
|
||||||
|
) -> str:
|
||||||
|
"""Build Dockerfile or render compose template."""
|
||||||
|
if tool_type.definition_type == "dockerfile":
|
||||||
|
image_tag = f"headquarter/{instance_name}:latest"
|
||||||
|
if tool_type.dockerfile_template:
|
||||||
|
returncode, _stdout, stderr = build_image(
|
||||||
|
instance_dir=instance_dir,
|
||||||
|
dockerfile=tool_type.dockerfile_template,
|
||||||
|
tag=image_tag,
|
||||||
|
build_context=tool_type.build_context,
|
||||||
|
)
|
||||||
|
if returncode != 0:
|
||||||
|
logger.error("Build failed for %s: %s", instance_name, stderr)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to build Docker image: {stderr[:500]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
compose_content = (
|
||||||
|
f'version: "3.8"\nservices:\n app:\n'
|
||||||
|
f' image: {image_tag}\n'
|
||||||
|
f' container_name: {instance_name}\n'
|
||||||
|
f' ports:\n - "{tool_port}:{tool_type.default_port}"\n'
|
||||||
|
f' volumes:\n - {repo.path}:/workspace\n'
|
||||||
|
f' restart: unless-stopped\n'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
variables = {
|
||||||
|
"REPO_PATH": repo.path,
|
||||||
|
"INSTANCE_NAME": instance_name,
|
||||||
|
"INSTANCE_ID": instance_name,
|
||||||
|
"TOOL_NAME": instance_name,
|
||||||
|
"TOOL_PORT": tool_port,
|
||||||
|
"USER_ID": str(user.id),
|
||||||
|
"PROJECT_ID": str(project_id),
|
||||||
|
}
|
||||||
|
compose_content = compose_svc.render_compose_template(
|
||||||
|
tool_type.compose_template, variables
|
||||||
|
)
|
||||||
|
|
||||||
|
compose_svc.write_compose_file(instance_dir, compose_content)
|
||||||
|
return os.path.join(instance_dir, "docker-compose.yml")
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_tool_configs(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: Any,
|
||||||
|
tool_type_id: Any,
|
||||||
|
project_id: Any,
|
||||||
|
) -> tuple[dict, dict, Any, Any, Any, dict, list]:
|
||||||
|
"""Fetch tool configs and return parsed values."""
|
||||||
|
env_vars: dict[str, str] = {}
|
||||||
|
config_files: dict[str, str] = {}
|
||||||
|
port_override = None
|
||||||
|
start_command = None
|
||||||
|
working_directory = None
|
||||||
|
extra_env_vars: dict[str, str] = {}
|
||||||
|
extra_volumes: list[dict] = []
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(ToolConfig)
|
||||||
|
.where(ToolConfig.user_id == user_id, ToolConfig.tool_type_id == tool_type_id)
|
||||||
|
.where((ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)))
|
||||||
|
)
|
||||||
|
configs = (await session.execute(query)).scalars().all()
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
if cfg.config_type == "env":
|
||||||
|
env_vars[cfg.key] = cfg.value
|
||||||
|
elif cfg.config_type == "file" and cfg.file_path:
|
||||||
|
config_files[cfg.file_path] = cfg.value
|
||||||
|
if cfg.port_override:
|
||||||
|
port_override = cfg.port_override
|
||||||
|
if cfg.start_command:
|
||||||
|
start_command = cfg.start_command
|
||||||
|
if cfg.working_directory:
|
||||||
|
working_directory = cfg.working_directory
|
||||||
|
if cfg.environment_variables:
|
||||||
|
extra_env_vars.update(cfg.environment_variables)
|
||||||
|
if cfg.volumes:
|
||||||
|
extra_volumes.extend(cfg.volumes)
|
||||||
|
|
||||||
|
env_vars.update(extra_env_vars)
|
||||||
|
return env_vars, config_files, port_override, start_command, working_directory, extra_env_vars, extra_volumes
|
||||||
|
|
||||||
|
|
||||||
|
async def _stage_configs_and_folders(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: Any,
|
||||||
|
project_id: Any,
|
||||||
|
instance_dir: str,
|
||||||
|
env_vars: dict[str, str],
|
||||||
|
config_files: dict[str, str],
|
||||||
|
extra_volumes: list[dict],
|
||||||
|
) -> tuple[str | None, list[dict]]:
|
||||||
|
"""Write env/config files and config folders."""
|
||||||
|
env_file_path: str | None = None
|
||||||
|
if env_vars:
|
||||||
|
env_file_path = compose_svc.write_env_file(instance_dir, env_vars)
|
||||||
|
if config_files:
|
||||||
|
config_staging.write_config_files(instance_dir, config_files)
|
||||||
|
|
||||||
|
folder_query = select(ConfigFolder).where(
|
||||||
|
ConfigFolder.user_id == user_id, ConfigFolder.is_active.is_(True)
|
||||||
|
)
|
||||||
|
folders = (await session.execute(folder_query)).scalars().all()
|
||||||
|
if folders:
|
||||||
|
folder_volumes = config_staging.write_config_folder_files(
|
||||||
|
instance_dir, folders, str(project_id)
|
||||||
|
)
|
||||||
|
extra_volumes.extend(folder_volumes)
|
||||||
|
|
||||||
|
return env_file_path, extra_volumes
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None:
|
||||||
|
"""Create Cloudflare tunnel for web-enabled tools."""
|
||||||
|
if "web" not in tool_type.interfaces or not tool_type.default_port:
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
tunnel_info = tunnel_svc.start_cloudflared_tunnel(
|
||||||
|
container_name=instance.container_name or instance.name,
|
||||||
|
port=tool_type.default_port,
|
||||||
|
)
|
||||||
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
|
instance.public_url = tunnel_info["url"]
|
||||||
|
instance.url = tunnel_info["url"]
|
||||||
|
logger.info("Created tunnel for instance %s: %s", instance.id, tunnel_info["url"])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
instance.status = "error"
|
||||||
|
instance.url = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_readiness_probe(
|
||||||
|
instance: ToolInstance, tool_type: ToolType
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Run readiness probe if configured."""
|
||||||
|
if not tool_type.readiness_probe or not instance.container_id:
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
probe = tool_type.readiness_probe
|
||||||
|
command = probe.get("command", "")
|
||||||
|
if not command:
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
return await execute_probe(
|
||||||
|
container_id=instance.container_id,
|
||||||
|
command=command,
|
||||||
|
timeout=probe.get("timeout", 30),
|
||||||
|
interval=probe.get("interval", 2),
|
||||||
|
)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import styles from "./features/git/CommitDialog.module.css";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
@@ -71,40 +72,40 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
|
|||||||
const hasChanges = diff.some((d) => d.type !== "same");
|
const hasChanges = diff.some((d) => d.type !== "same");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dialog-overlay">
|
<div className={styles.dialogOverlay}>
|
||||||
<div className="commit-dialog">
|
<div className={styles.commitDialog}>
|
||||||
<div className="dialog-header">
|
<div className={styles.dialogHeader}>
|
||||||
<h3>Commit Changes</h3>
|
<h3>Commit Changes</h3>
|
||||||
<button className="dialog-close" onClick={onCancel} type="button">
|
<button className={styles.dialogClose} onClick={onCancel} type="button">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dialog-body">
|
<div className={styles.dialogBody}>
|
||||||
<p className="file-info">
|
<p className={styles.fileInfo}>
|
||||||
Editing: <strong>{filePath}</strong>
|
Editing: <strong>{filePath}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!hasChanges && (
|
{!hasChanges && (
|
||||||
<div className="warning-message">No changes to commit</div>
|
<div className={styles.warningMessage}>No changes to commit</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<div className="diff-preview">
|
<div className={styles.diffPreview}>
|
||||||
<h4>Changes</h4>
|
<h4>Changes</h4>
|
||||||
<div className="diff-content">
|
<div className={styles.diffContent}>
|
||||||
{diff.map((line, i) => (
|
{diff.map((line, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`diff-line diff-${line.type}`}
|
className={`${styles.diffLine} ${line.type === "added" ? styles.diffAdded : line.type === "removed" ? styles.diffRemoved : styles.diffSame}`}
|
||||||
>
|
>
|
||||||
<span className="diff-line-number">{line.lineNum}</span>
|
<span className={styles.diffLineNumber}>{line.lineNum}</span>
|
||||||
<span className="diff-marker">
|
<span className={styles.diffMarker}>
|
||||||
{line.type === "added" && "+"}
|
{line.type === "added" && "+"}
|
||||||
{line.type === "removed" && "-"}
|
{line.type === "removed" && "-"}
|
||||||
{line.type === "same" && " "}
|
{line.type === "same" && " "}
|
||||||
</span>
|
</span>
|
||||||
<span className="diff-line-content">{line.line}</span>
|
<span className={styles.diffLineContent}>{line.line}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +126,7 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
|
|||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dialog-footer">
|
<div className={styles.dialogFooter}>
|
||||||
<button
|
<button
|
||||||
className="btn-secondary"
|
className="btn-secondary"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
.commitDialog {
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogHeader h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogClose:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogBody {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInfo {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffPreview {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffPreview h4 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffContent {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffLine {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffLineNumber {
|
||||||
|
color: var(--muted);
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffMarker {
|
||||||
|
width: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffAdded {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffAdded .diffMarker {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffRemoved {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffRemoved .diffMarker {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffSame {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffLineContent {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningMessage {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #d97706;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.fileEditor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileEditorToolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileEditorContent {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
.gitToolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarGroup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButton:hover:not(:disabled) {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButton:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButtonPrimary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branchSelect {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarError {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarInput {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newBranchForm {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusSummary {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadgeModified {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadgeAdded {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadgeDeleted {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadgeUntracked {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
.mergeForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeForm .formField {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeForm label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeForm select,
|
||||||
|
.mergeForm input,
|
||||||
|
.mergeForm textarea {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeForm textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputDisabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.successText {
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
.terminalWrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid #3e3e3e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalStatus {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalStatus .statusDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalStatus .statusText {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #d4d4d4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalClose {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalClose:hover {
|
||||||
|
background: #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalContainer {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalContainer :global(.xterm-viewport) {
|
||||||
|
background: #1e1e1e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalOverlayContent {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border: 1px solid #3e3e3e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalOverlayContent h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: #f14c4c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalOverlayContent p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalOverlayActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalReconnectBanner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: #3e3e3e;
|
||||||
|
color: #f5f543;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.terminalOverlayContent {
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import styles from "./features/git/FileEditor.module.css";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { apiClient } from "../api/client";
|
import { apiClient } from "../api/client";
|
||||||
@@ -154,8 +155,8 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
if (error) return <p className="error-text">{error}</p>;
|
if (error) return <p className="error-text">{error}</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-editor">
|
<div className={styles.fileEditor}>
|
||||||
<div className="file-editor-toolbar">
|
<div className={styles.fileEditorToolbar}>
|
||||||
<div className="file-breadcrumbs">
|
<div className="file-breadcrumbs">
|
||||||
{filePath.split("/").map((part, i, arr) => (
|
{filePath.split("/").map((part, i, arr) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
@@ -166,7 +167,7 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="file-actions">
|
<div className={styles.fileActions}>
|
||||||
{mode === "view" && !isBinary && (
|
{mode === "view" && !isBinary && (
|
||||||
<button
|
<button
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
@@ -210,7 +211,7 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="file-editor-content">
|
<div className={styles.fileEditorContent}>
|
||||||
{mode === "view" && (
|
{mode === "view" && (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
code={content}
|
code={content}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "../api/git_repositories";
|
} from "../api/git_repositories";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { MergeDialog } from "./merge-dialog";
|
import { MergeDialog } from "./merge-dialog";
|
||||||
|
import styles from "./features/git/GitToolbar.module.css";
|
||||||
|
|
||||||
interface GitToolbarProps {
|
interface GitToolbarProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -134,16 +135,16 @@ export const GitToolbar = ({
|
|||||||
const canSync = hasRemote;
|
const canSync = hasRemote;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-toolbar">
|
<div className={styles.gitToolbar}>
|
||||||
{error && <div className="toolbar-error">{error}</div>}
|
{error && <div className={styles.toolbarError}>{error}</div>}
|
||||||
|
|
||||||
<div className="toolbar-row">
|
<div className={styles.toolbarRow}>
|
||||||
<div className="toolbar-group">
|
<div className={styles.toolbarGroup}>
|
||||||
<select
|
<select
|
||||||
value={currentBranch}
|
value={currentBranch}
|
||||||
onChange={(e) => handleCheckout(e.target.value)}
|
onChange={(e) => handleCheckout(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="branch-select"
|
className={styles.branchSelect}
|
||||||
>
|
>
|
||||||
{branches.map((b) => (
|
{branches.map((b) => (
|
||||||
<option key={b} value={b}>
|
<option key={b} value={b}>
|
||||||
@@ -158,7 +159,7 @@ export const GitToolbar = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={() => setShowNewBranch(!showNewBranch)}
|
onClick={() => setShowNewBranch(!showNewBranch)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -167,9 +168,9 @@ export const GitToolbar = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-group">
|
<div className={styles.toolbarGroup}>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={handleFetch}
|
onClick={handleFetch}
|
||||||
disabled={loading || !canSync}
|
disabled={loading || !canSync}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -177,25 +178,25 @@ export const GitToolbar = ({
|
|||||||
<Icon name="fetch" size="sm" /> Fetch
|
<Icon name="fetch" size="sm" /> Fetch
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={handlePull}
|
onClick={handlePull}
|
||||||
disabled={loading || !canSync}
|
disabled={loading || !canSync}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="pull" size="sm" /> Pull
|
<Icon name="pull" size="sm" /> Pull
|
||||||
{status?.behind ? <span className="badge">{status.behind}</span> : null}
|
{status?.behind ? <span className={styles.badge}>{status.behind}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={handlePush}
|
onClick={handlePush}
|
||||||
disabled={loading || !canSync || !status?.ahead}
|
disabled={loading || !canSync || !status?.ahead}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="push" size="sm" /> Push
|
<Icon name="push" size="sm" /> Push
|
||||||
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
|
{status?.ahead ? <span className={styles.badge}>{status.ahead}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={() => setShowMergeDialog(true)}
|
onClick={() => setShowMergeDialog(true)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -206,18 +207,18 @@ export const GitToolbar = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewBranch && (
|
{showNewBranch && (
|
||||||
<div className="toolbar-row new-branch-form">
|
<div className={`${styles.toolbarRow} ${styles.newBranchForm}`}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Branch name"
|
placeholder="Branch name"
|
||||||
value={newBranchName}
|
value={newBranchName}
|
||||||
onChange={(e) => setNewBranchName(e.target.value)}
|
onChange={(e) => setNewBranchName(e.target.value)}
|
||||||
className="toolbar-input"
|
className={styles.toolbarInput}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={newBranchBase}
|
value={newBranchBase}
|
||||||
onChange={(e) => setNewBranchBase(e.target.value)}
|
onChange={(e) => setNewBranchBase(e.target.value)}
|
||||||
className="toolbar-input"
|
className={styles.toolbarInput}
|
||||||
>
|
>
|
||||||
<option value="">Base: HEAD</option>
|
<option value="">Base: HEAD</option>
|
||||||
{branches.map((b) => (
|
{branches.map((b) => (
|
||||||
@@ -225,7 +226,7 @@ export const GitToolbar = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button primary"
|
className={styles.toolbarButtonPrimary}
|
||||||
onClick={handleCreateBranch}
|
onClick={handleCreateBranch}
|
||||||
disabled={loading || !newBranchName.trim()}
|
disabled={loading || !newBranchName.trim()}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -233,7 +234,7 @@ export const GitToolbar = ({
|
|||||||
<Icon name="add" size="sm" /> Create
|
<Icon name="add" size="sm" /> Create
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={() => setShowNewBranch(false)}
|
onClick={() => setShowNewBranch(false)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -243,11 +244,11 @@ export const GitToolbar = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasChanges && status && (
|
{hasChanges && status && (
|
||||||
<div className="toolbar-row status-summary">
|
<div className={`${styles.toolbarRow} ${styles.statusSummary}`}>
|
||||||
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
|
{status.modified.length > 0 && <span className={styles.statusBadgeModified}><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
|
||||||
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
|
{status.added.length > 0 && <span className={styles.statusBadgeAdded}><Icon name="add" size="sm" /> {status.added.length} added</span>}
|
||||||
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
|
{status.deleted.length > 0 && <span className={styles.statusBadgeDeleted}><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
|
||||||
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
|
{status.untracked.length > 0 && <span className={styles.statusBadgeUntracked}><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import styles from "./features/git/MergeDialog.module.css";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { mergeBranches } from "../api/git_repositories";
|
import { mergeBranches } from "../api/git_repositories";
|
||||||
@@ -65,8 +66,8 @@ export const MergeDialog = ({
|
|||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2>Merge Branch</h2>
|
<h2>Merge Branch</h2>
|
||||||
|
|
||||||
<div className="merge-form">
|
<div className={styles.mergeForm}>
|
||||||
<div className="form-field">
|
<div className={styles.formField}>
|
||||||
<label>Source Branch (merge from)</label>
|
<label>Source Branch (merge from)</label>
|
||||||
<select
|
<select
|
||||||
value={sourceBranch}
|
value={sourceBranch}
|
||||||
@@ -82,17 +83,17 @@ export const MergeDialog = ({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-field">
|
<div className={styles.formField}>
|
||||||
<label>Target Branch (merge into)</label>
|
<label>Target Branch (merge into)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={currentBranch}
|
value={currentBranch}
|
||||||
disabled
|
disabled
|
||||||
className="input-disabled"
|
className={styles.inputDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-field">
|
<div className={styles.formField}>
|
||||||
<label>Commit Message (optional)</label>
|
<label>Commit Message (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={commitMessage}
|
value={commitMessage}
|
||||||
@@ -105,7 +106,7 @@ export const MergeDialog = ({
|
|||||||
|
|
||||||
{error && <div className="error-text">{error}</div>}
|
{error && <div className="error-text">{error}</div>}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="success-text">Merge successful!</div>
|
<div className={styles.successText}>Merge successful!</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
ServerControlMessage,
|
ServerControlMessage,
|
||||||
TerminalConnectionState,
|
TerminalConnectionState,
|
||||||
} from "../types/terminal";
|
} from "../types/terminal";
|
||||||
|
import styles from "./features/terminal/TerminalComponent.module.css";
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
@@ -231,11 +232,11 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
}, [state.status, sendResize]);
|
}, [state.status, sendResize]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="terminal-wrapper">
|
<div className={styles.terminalWrapper}>
|
||||||
<div className="terminal-header">
|
<div className={styles.terminalHeader}>
|
||||||
<div className="terminal-status">
|
<div className={styles.terminalStatus}>
|
||||||
<span
|
<span
|
||||||
className="status-dot"
|
className={styles.statusDot}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: STATUS_DOT_COLORS[state.status],
|
backgroundColor: STATUS_DOT_COLORS[state.status],
|
||||||
}}
|
}}
|
||||||
@@ -246,9 +247,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
: getStatusText(state)
|
: getStatusText(state)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="status-text">{getStatusText(state)}</span>
|
<span className={styles.statusText}>{getStatusText(state)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="terminal-actions">
|
<div className={styles.terminalActions}>
|
||||||
{state.status === "disconnected" && (
|
{state.status === "disconnected" && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
@@ -259,7 +260,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button className="terminal-close" onClick={onClose} type="button">
|
<button className={styles.terminalClose} onClick={onClose} type="button">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -267,11 +268,11 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sessionEnded && (
|
{sessionEnded && (
|
||||||
<div className="terminal-overlay">
|
<div className={styles.terminalOverlay}>
|
||||||
<div className="terminal-overlay-content">
|
<div className={styles.terminalOverlayContent}>
|
||||||
<h3>Session Ended</h3>
|
<h3>Session Ended</h3>
|
||||||
<p>{sessionEnded.message}</p>
|
<p>{sessionEnded.message}</p>
|
||||||
<div className="terminal-overlay-actions">
|
<div className={styles.terminalOverlayActions}>
|
||||||
<button
|
<button
|
||||||
className="primary-button small"
|
className="primary-button small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -297,13 +298,13 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{state.status === "reconnecting" && (
|
{state.status === "reconnecting" && (
|
||||||
<div className="terminal-reconnect-banner">
|
<div className={styles.terminalReconnectBanner}>
|
||||||
<span className="spinner" />
|
<span className={styles.spinner} />
|
||||||
{state.error}
|
{state.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={terminalRef} className="terminal-container" />
|
<div ref={terminalRef} className={styles.terminalContainer} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1514,429 +1514,6 @@ a {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Git Toolbar - Top Bar Styles */
|
|
||||||
.git-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
min-height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-button:hover:not(:disabled) {
|
|
||||||
background: var(--brand);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-button.primary {
|
|
||||||
background: var(--brand);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.branch-select {
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
padding: 0 4px;
|
|
||||||
background: var(--brand);
|
|
||||||
color: white;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-error {
|
|
||||||
color: #ef4444;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-input {
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-branch-form {
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-summary {
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.modified {
|
|
||||||
background: rgba(245, 158, 11, 0.1);
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.added {
|
|
||||||
background: rgba(16, 185, 129, 0.1);
|
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.deleted {
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.untracked {
|
|
||||||
background: rgba(107, 114, 128, 0.1);
|
|
||||||
color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* File Editor */
|
|
||||||
.file-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-editor-toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-editor-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Syntax Highlighter */
|
|
||||||
.syntax-highlighter {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlighter-toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-badge {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
background: var(--bg);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-button {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-button:hover {
|
|
||||||
background: var(--brand);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-container {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-numbers {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1rem 0.5rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
text-align: right;
|
|
||||||
user-select: none;
|
|
||||||
min-width: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-number {
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-block {
|
|
||||||
flex: 1;
|
|
||||||
margin: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
overflow: visible;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-block code {
|
|
||||||
display: block;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code Editor */
|
|
||||||
.code-editor {
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-textarea {
|
|
||||||
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-textarea-input {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ink);
|
|
||||||
caret-color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-line {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-line-number {
|
|
||||||
display: inline-block;
|
|
||||||
width: 3rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
text-align: right;
|
|
||||||
color: var(--muted);
|
|
||||||
user-select: none;
|
|
||||||
background: var(--panel);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-line-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Commit Dialog */
|
|
||||||
.dialog-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.commit-dialog {
|
|
||||||
background: var(--panel);
|
|
||||||
border-radius: 14px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow: auto;
|
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 0;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-close:hover {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-info {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-preview {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-preview h4 {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-content {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: auto;
|
|
||||||
max-height: 300px;
|
|
||||||
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-line {
|
|
||||||
display: flex;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-line-number {
|
|
||||||
color: var(--muted);
|
|
||||||
min-width: 2rem;
|
|
||||||
text-align: right;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-marker {
|
|
||||||
width: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-added {
|
|
||||||
background: rgba(16, 185, 129, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-added .diff-marker {
|
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-removed {
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-removed .diff-marker {
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-same {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-message {
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: rgba(245, 158, 11, 0.1);
|
|
||||||
color: #d97706;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
@@ -2676,160 +2253,3 @@ a.nav-item,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Responsive Terminal — Updated
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.terminal-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #1e1e1e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: #2d2d2d;
|
|
||||||
border-bottom: 1px solid #3e3e3e;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-status .status-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-status .status-text {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #d4d4d4;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-close {
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #666;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #d4d4d4;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-close:hover {
|
|
||||||
background: #3e3e3e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-container {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-container .xterm-viewport {
|
|
||||||
background: #1e1e1e !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Terminal overlay for session ended */
|
|
||||||
.terminal-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
display: grid;
|
|
||||||
place-content: center;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-overlay-content {
|
|
||||||
background: #2d2d2d;
|
|
||||||
border: 1px solid #3e3e3e;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
max-width: 400px;
|
|
||||||
color: #d4d4d4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-overlay-content h3 {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
color: #f14c4c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-overlay-content p {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-overlay-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reconnect banner */
|
|
||||||
.terminal-reconnect-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
background: #3e3e3e;
|
|
||||||
color: #f5f543;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border: 2px solid currentColor;
|
|
||||||
border-right-color: transparent;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.75s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive terminal */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.terminal-page {
|
|
||||||
padding: var(--space-2);
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-page-header h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-overlay-content {
|
|
||||||
margin: 0 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Task 2.2 Apply Report: Extract CSS Modules for Terminal and Git Components
|
||||||
|
|
||||||
|
**Status:** Success
|
||||||
|
|
||||||
|
## Files Created (5)
|
||||||
|
|
||||||
|
- `apps/web/src/components/features/terminal/TerminalComponent.module.css` — Terminal component styles (extracted from styles.css)
|
||||||
|
- `apps/web/src/components/features/git/GitToolbar.module.css` — Git toolbar styles
|
||||||
|
- `apps/web/src/components/features/git/CommitDialog.module.css` — Commit dialog styles
|
||||||
|
- `apps/web/src/components/features/git/MergeDialog.module.css` — Merge dialog styles
|
||||||
|
- `apps/web/src/components/features/git/FileEditor.module.css` — File editor styles
|
||||||
|
|
||||||
|
## Files Modified (6)
|
||||||
|
|
||||||
|
- `apps/web/src/components/terminal.tsx` — Import CSS module, replace className strings with styles.* references
|
||||||
|
- `apps/web/src/components/git-toolbar.tsx` — Import CSS module, replace className strings
|
||||||
|
- `apps/web/src/components/commit-dialog.tsx` — Import CSS module, replace className strings
|
||||||
|
- `apps/web/src/components/merge-dialog.tsx` — Import CSS module, replace className strings
|
||||||
|
- `apps/web/src/components/file-editor.tsx` — Import CSS module, replace className strings
|
||||||
|
- `apps/web/src/styles.css` — Removed extracted terminal and git component CSS rules (~441 lines removed)
|
||||||
|
|
||||||
|
## Quality Gate Results
|
||||||
|
|
||||||
|
- `npm run typecheck` (frontend): **PASS** — zero errors
|
||||||
|
- `npm run lint` (frontend): **PASS** — zero warnings
|
||||||
|
- `npm run build` (frontend): **PASS** — build succeeds in 9.70s
|
||||||
|
- No remaining `.terminal-*`, `.file-editor`, or `.commit-dialog` rules in styles.css
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- CSS classes converted from kebab-case to camelCase for CSS Modules usage
|
||||||
|
- Generic/shared classes (form-group, btn-primary, btn-secondary, error-message) remain in styles.css
|
||||||
|
- Dynamic diff line classes handled with conditional className assignment
|
||||||
|
- `styles.css` reduced from 2696 lines to 2255 lines
|
||||||
Reference in New Issue
Block a user