Source code for simvx.editor.project_registry

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


from __future__ import annotations

import json
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 = "project.simvx"
_LEGACY_PROJECT_FILE = "project.json"


[docs] @dataclass class RecentProject: path: str # Absolute path to project directory name: str # From project.simvx 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.simvx metadata from a directory. Returns None if invalid.""" d = Path(project_dir) for fname in (_PROJECT_FILE, _LEGACY_PROJECT_FILE): pf = d / fname if pf.exists(): try: data = json.loads(pf.read_text()) name = data.get("project_name", d.name) # Guess template type from files 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(), ) except (json.JSONDecodeError, OSError): pass return None
[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 ((d / _PROJECT_FILE).exists() or (d / _LEGACY_PROJECT_FILE).exists()): 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 a project.simvx or legacy project.json.""" d = Path(directory) return (d / _PROJECT_FILE).exists() or (d / _LEGACY_PROJECT_FILE).exists()