Source code for simvx.editor.project_registry

"""ProjectRegistry — recent project tracking with persistence via AppConfig."""

import logging
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from pathlib import Path

from simvx.core.config import AppConfig

log = logging.getLogger(__name__)

_PROJECT_FILE = "simvx.toml"

[docs] @dataclass class RecentProject: path: str # Absolute path to project directory name: str # From simvx.toml template_type: str # "Empty" | "2D Game" | "3D Game" | "Unknown" last_opened: str # ISO 8601 timestamp
[docs] class ProjectRegistry: """Tracks recently opened projects, persisted via AppConfig.general.recent_projects.""" MAX_RECENT = 20 def __init__(self, config: AppConfig | None = None) -> None: self._config = config or AppConfig() self.recent: list[RecentProject] = []
[docs] def add(self, project_dir: str) -> None: """Add or bump a project to the top of the recent list.""" project_dir = str(Path(project_dir).resolve()) # Remove existing entry self.recent = [r for r in self.recent if r.path != project_dir] # Scan metadata entry = self.scan(project_dir) if entry is None: entry = RecentProject( path=project_dir, name=Path(project_dir).name, template_type="Unknown", last_opened=datetime.now(UTC).isoformat(), ) entry.last_opened = datetime.now(UTC).isoformat() self.recent.insert(0, entry) self.recent = self.recent[: self.MAX_RECENT]
[docs] def remove(self, project_dir: str) -> None: """Remove a project from the recent list (does not delete files).""" project_dir = str(Path(project_dir).resolve()) self.recent = [r for r in self.recent if r.path != project_dir]
[docs] def scan(self, project_dir: str) -> RecentProject | None: """Read project metadata from a directory. Returns None if invalid.""" import tomllib d = Path(project_dir) pf = d / _PROJECT_FILE if not pf.exists(): return None try: data = tomllib.loads(pf.read_text()) except (tomllib.TOMLDecodeError, OSError): return None proj_section = data.get("project", {}) name = proj_section.get("name", data.get("name", d.name)) ttype = "Unknown" if (d / "main.py").exists(): content = (d / "main.py").read_text(errors="replace") if "Camera3D" in content or "Node3D" in content: ttype = "3D Game" elif "Camera2D" in content or "Node2D" in content: ttype = "2D Game" elif not any(d.glob("*.py")): ttype = "Empty" return RecentProject( path=str(d.resolve()), name=name, template_type=ttype, last_opened=datetime.now(UTC).isoformat(), )
[docs] def refresh(self) -> None: """Re-scan all entries, pruning missing directories.""" valid = [] for entry in self.recent: d = Path(entry.path) if d.is_dir() and self.has_project_file(d): scanned = self.scan(entry.path) if scanned: scanned.last_opened = entry.last_opened valid.append(scanned) self.recent = valid
[docs] def load(self) -> None: """Load from AppConfig.general.recent_projects.""" self._config.load() self.recent = [] for d in self._config.general.recent_projects: try: self.recent.append(RecentProject(**{k: d[k] for k in ("path", "name", "template_type", "last_opened")})) except (KeyError, TypeError): continue
[docs] def save(self) -> None: """Persist to AppConfig.general.recent_projects.""" self._config.general.recent_projects = [asdict(r) for r in self.recent] self._config.save()
[docs] @staticmethod def has_project_file(directory: str | Path) -> bool: """Check if a directory contains simvx.toml.""" return (Path(directory) / _PROJECT_FILE).exists()