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()