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."""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
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:
|
||||
"""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 { Icon } from "./icon";
|
||||
@@ -71,40 +72,40 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
|
||||
const hasChanges = diff.some((d) => d.type !== "same");
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay">
|
||||
<div className="commit-dialog">
|
||||
<div className="dialog-header">
|
||||
<div className={styles.dialogOverlay}>
|
||||
<div className={styles.commitDialog}>
|
||||
<div className={styles.dialogHeader}>
|
||||
<h3>Commit Changes</h3>
|
||||
<button className="dialog-close" onClick={onCancel} type="button">
|
||||
<button className={styles.dialogClose} onClick={onCancel} type="button">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dialog-body">
|
||||
<p className="file-info">
|
||||
<div className={styles.dialogBody}>
|
||||
<p className={styles.fileInfo}>
|
||||
Editing: <strong>{filePath}</strong>
|
||||
</p>
|
||||
|
||||
{!hasChanges && (
|
||||
<div className="warning-message">No changes to commit</div>
|
||||
<div className={styles.warningMessage}>No changes to commit</div>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
<div className="diff-preview">
|
||||
<div className={styles.diffPreview}>
|
||||
<h4>Changes</h4>
|
||||
<div className="diff-content">
|
||||
<div className={styles.diffContent}>
|
||||
{diff.map((line, i) => (
|
||||
<div
|
||||
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="diff-marker">
|
||||
<span className={styles.diffLineNumber}>{line.lineNum}</span>
|
||||
<span className={styles.diffMarker}>
|
||||
{line.type === "added" && "+"}
|
||||
{line.type === "removed" && "-"}
|
||||
{line.type === "same" && " "}
|
||||
</span>
|
||||
<span className="diff-line-content">{line.line}</span>
|
||||
<span className={styles.diffLineContent}>{line.line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -125,7 +126,7 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="dialog-footer">
|
||||
<div className={styles.dialogFooter}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
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 { useSearchParams } from "react-router-dom";
|
||||
import { apiClient } from "../api/client";
|
||||
@@ -154,8 +155,8 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="file-editor">
|
||||
<div className="file-editor-toolbar">
|
||||
<div className={styles.fileEditor}>
|
||||
<div className={styles.fileEditorToolbar}>
|
||||
<div className="file-breadcrumbs">
|
||||
{filePath.split("/").map((part, i, arr) => (
|
||||
<span key={i}>
|
||||
@@ -166,7 +167,7 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="file-actions">
|
||||
<div className={styles.fileActions}>
|
||||
{mode === "view" && !isBinary && (
|
||||
<button
|
||||
className="btn-primary"
|
||||
@@ -210,7 +211,7 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="file-editor-content">
|
||||
<div className={styles.fileEditorContent}>
|
||||
{mode === "view" && (
|
||||
<SyntaxHighlighter
|
||||
code={content}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../api/git_repositories";
|
||||
import { Icon } from "./icon";
|
||||
import { MergeDialog } from "./merge-dialog";
|
||||
import styles from "./features/git/GitToolbar.module.css";
|
||||
|
||||
interface GitToolbarProps {
|
||||
projectId: string;
|
||||
@@ -134,16 +135,16 @@ export const GitToolbar = ({
|
||||
const canSync = hasRemote;
|
||||
|
||||
return (
|
||||
<div className="git-toolbar">
|
||||
{error && <div className="toolbar-error">{error}</div>}
|
||||
<div className={styles.gitToolbar}>
|
||||
{error && <div className={styles.toolbarError}>{error}</div>}
|
||||
|
||||
<div className="toolbar-row">
|
||||
<div className="toolbar-group">
|
||||
<div className={styles.toolbarRow}>
|
||||
<div className={styles.toolbarGroup}>
|
||||
<select
|
||||
value={currentBranch}
|
||||
onChange={(e) => handleCheckout(e.target.value)}
|
||||
disabled={loading}
|
||||
className="branch-select"
|
||||
className={styles.branchSelect}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
@@ -158,7 +159,7 @@ export const GitToolbar = ({
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
className={styles.toolbarButton}
|
||||
onClick={() => setShowNewBranch(!showNewBranch)}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
@@ -167,9 +168,9 @@ export const GitToolbar = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-group">
|
||||
<div className={styles.toolbarGroup}>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
className={styles.toolbarButton}
|
||||
onClick={handleFetch}
|
||||
disabled={loading || !canSync}
|
||||
type="button"
|
||||
@@ -177,25 +178,25 @@ export const GitToolbar = ({
|
||||
<Icon name="fetch" size="sm" /> Fetch
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
className={styles.toolbarButton}
|
||||
onClick={handlePull}
|
||||
disabled={loading || !canSync}
|
||||
type="button"
|
||||
>
|
||||
<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
|
||||
className="toolbar-button"
|
||||
className={styles.toolbarButton}
|
||||
onClick={handlePush}
|
||||
disabled={loading || !canSync || !status?.ahead}
|
||||
type="button"
|
||||
>
|
||||
<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
|
||||
className="toolbar-button"
|
||||
className={styles.toolbarButton}
|
||||
onClick={() => setShowMergeDialog(true)}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
@@ -206,18 +207,18 @@ export const GitToolbar = ({
|
||||
</div>
|
||||
|
||||
{showNewBranch && (
|
||||
<div className="toolbar-row new-branch-form">
|
||||
<div className={`${styles.toolbarRow} ${styles.newBranchForm}`}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Branch name"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
className="toolbar-input"
|
||||
className={styles.toolbarInput}
|
||||
/>
|
||||
<select
|
||||
value={newBranchBase}
|
||||
onChange={(e) => setNewBranchBase(e.target.value)}
|
||||
className="toolbar-input"
|
||||
className={styles.toolbarInput}
|
||||
>
|
||||
<option value="">Base: HEAD</option>
|
||||
{branches.map((b) => (
|
||||
@@ -225,7 +226,7 @@ export const GitToolbar = ({
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="toolbar-button primary"
|
||||
className={styles.toolbarButtonPrimary}
|
||||
onClick={handleCreateBranch}
|
||||
disabled={loading || !newBranchName.trim()}
|
||||
type="button"
|
||||
@@ -233,7 +234,7 @@ export const GitToolbar = ({
|
||||
<Icon name="add" size="sm" /> Create
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
className={styles.toolbarButton}
|
||||
onClick={() => setShowNewBranch(false)}
|
||||
type="button"
|
||||
>
|
||||
@@ -243,11 +244,11 @@ export const GitToolbar = ({
|
||||
)}
|
||||
|
||||
{hasChanges && status && (
|
||||
<div className="toolbar-row status-summary">
|
||||
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
|
||||
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
|
||||
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
|
||||
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
|
||||
<div className={`${styles.toolbarRow} ${styles.statusSummary}`}>
|
||||
{status.modified.length > 0 && <span className={styles.statusBadgeModified}><Icon name="edit" size="sm" /> {status.modified.length} modified</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={styles.statusBadgeDeleted}><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
|
||||
{status.untracked.length > 0 && <span className={styles.statusBadgeUntracked}><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import styles from "./features/git/MergeDialog.module.css";
|
||||
import { useState } from "react";
|
||||
|
||||
import { mergeBranches } from "../api/git_repositories";
|
||||
@@ -65,8 +66,8 @@ export const MergeDialog = ({
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Merge Branch</h2>
|
||||
|
||||
<div className="merge-form">
|
||||
<div className="form-field">
|
||||
<div className={styles.mergeForm}>
|
||||
<div className={styles.formField}>
|
||||
<label>Source Branch (merge from)</label>
|
||||
<select
|
||||
value={sourceBranch}
|
||||
@@ -82,17 +83,17 @@ export const MergeDialog = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<div className={styles.formField}>
|
||||
<label>Target Branch (merge into)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentBranch}
|
||||
disabled
|
||||
className="input-disabled"
|
||||
className={styles.inputDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<div className={styles.formField}>
|
||||
<label>Commit Message (optional)</label>
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
@@ -105,7 +106,7 @@ export const MergeDialog = ({
|
||||
|
||||
{error && <div className="error-text">{error}</div>}
|
||||
{success && (
|
||||
<div className="success-text">Merge successful!</div>
|
||||
<div className={styles.successText}>Merge successful!</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ServerControlMessage,
|
||||
TerminalConnectionState,
|
||||
} from "../types/terminal";
|
||||
import styles from "./features/terminal/TerminalComponent.module.css";
|
||||
|
||||
interface TerminalProps {
|
||||
instanceId: string;
|
||||
@@ -231,11 +232,11 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [state.status, sendResize]);
|
||||
|
||||
return (
|
||||
<div className="terminal-wrapper">
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-status">
|
||||
<div className={styles.terminalWrapper}>
|
||||
<div className={styles.terminalHeader}>
|
||||
<div className={styles.terminalStatus}>
|
||||
<span
|
||||
className="status-dot"
|
||||
className={styles.statusDot}
|
||||
style={{
|
||||
backgroundColor: STATUS_DOT_COLORS[state.status],
|
||||
}}
|
||||
@@ -246,9 +247,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: getStatusText(state)
|
||||
}
|
||||
/>
|
||||
<span className="status-text">{getStatusText(state)}</span>
|
||||
<span className={styles.statusText}>{getStatusText(state)}</span>
|
||||
</div>
|
||||
<div className="terminal-actions">
|
||||
<div className={styles.terminalActions}>
|
||||
{state.status === "disconnected" && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
@@ -259,7 +260,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button className="terminal-close" onClick={onClose} type="button">
|
||||
<button className={styles.terminalClose} onClick={onClose} type="button">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
@@ -267,11 +268,11 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
|
||||
{sessionEnded && (
|
||||
<div className="terminal-overlay">
|
||||
<div className="terminal-overlay-content">
|
||||
<div className={styles.terminalOverlay}>
|
||||
<div className={styles.terminalOverlayContent}>
|
||||
<h3>Session Ended</h3>
|
||||
<p>{sessionEnded.message}</p>
|
||||
<div className="terminal-overlay-actions">
|
||||
<div className={styles.terminalOverlayActions}>
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => {
|
||||
@@ -297,13 +298,13 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
|
||||
{state.status === "reconnecting" && (
|
||||
<div className="terminal-reconnect-banner">
|
||||
<span className="spinner" />
|
||||
<div className={styles.terminalReconnectBanner}>
|
||||
<span className={styles.spinner} />
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={terminalRef} className="terminal-container" />
|
||||
<div ref={terminalRef} className={styles.terminalContainer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1514,429 +1514,6 @@ a {
|
||||
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;
|
||||
gap: 0.75rem;
|
||||
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