Seed bundled demos under top-level demos/ instead of code/

Move canonical sample scripts to a sibling folder of code/ and lib/ so
user projects stay separate from shipped examples. Backend seeding,
writable paths, and docs follow the new layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-14 22:34:12 +12:00
parent d355174f5a
commit e3400120d3
9 changed files with 63 additions and 53 deletions

View File

@@ -29,9 +29,10 @@ load_env_file(PROJECT_ROOT / ".env")
_default_workspace = PROJECT_ROOT / "workspace"
WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", str(_default_workspace))).resolve()
# Canonical demo source. Files here are the single source of truth for the
# editor's "Reset demos" button and per-user account seeding. They ship with
# the static bundle (`/static/bundled-demos/...`) so a static-only host
# also exposes them. `workspace/` is intentionally NOT used for canonical
# data — it is treated as runtime/user state and is gitignored.
# Canonical demo bundle root (`manifest.json` lives here). Sample `.py`
# sources live under `demo/` (same idea as `bundled-lib/` for shared modules).
# They ship with the static bundle (`/static/bundled-demos/...`) so a
# static-only host also exposes them. `workspace/` is intentionally NOT used
# for canonical data — it is treated as runtime/user state and is gitignored.
BUNDLED_DEMOS_DIR = STATIC_DIR / "bundled-demos"
BUNDLED_DEMOS_CODE_DIR = BUNDLED_DEMOS_DIR / "demo"

View File

@@ -20,10 +20,10 @@ from editor_app.services import accounts, user_workspace
@asynccontextmanager
async def lifespan(_app: FastAPI):
# `workspace/` is gitignored runtime state. On a fresh clone it doesn't
# exist, and in no-auth dev mode the file tree's `code/` would otherwise
# be empty — seed every bundled demo so `pipenv run dev` after `git
# clone` Just Works without needing user accounts. Files already in
# `code/` are left alone (user edits are preserved across restarts).
# exist, and in no-auth dev mode the file tree would otherwise be empty
# — seed every bundled demo under top-level `demos/` (next to `code/`)
# so `pipenv run dev` after `git clone` Just Works without needing user
# accounts. Existing files are left alone (user edits are preserved).
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
user_workspace.seed_all_bundled_demos(WORKSPACE_ROOT)

View File

@@ -7,7 +7,7 @@ from editor_app import config
from editor_app.models import FileInfo
LIB_DIR_NAME = "lib"
WRITABLE_ROOTS = {"code"}
WRITABLE_ROOTS = {"code", "demos"}
def _workspace_root(workspace_root: Path | None = None) -> Path:
@@ -33,6 +33,9 @@ def normalize_relative_path(relative_path: str) -> str:
if len(parts) >= 2 and parts[0] == "code":
while len(parts) >= 2 and parts[0] == parts[1] == "code":
parts.pop(1)
if len(parts) >= 2 and parts[0] == "demos":
while len(parts) >= 2 and parts[0] == parts[1] == "demos":
parts.pop(1)
return "/".join(parts)
@@ -84,7 +87,7 @@ def _ensure_writable_path(target_path: Path, workspace_root: Path | None = None)
if not _is_writable_path(target_path, workspace_root):
raise HTTPException(
status_code=403,
detail="Only code/ is writable (lib is read-only)",
detail="Only code/ and demos/ are writable (lib is read-only)",
)

View File

@@ -6,13 +6,14 @@ from pathlib import Path
from editor_app import config
_BUNDLED_DEMO_PY_DIR = config.BUNDLED_DEMOS_CODE_DIR
# Top-level workspace folder for shipped samples (sibling of ``code/``, like ``lib/``).
_EDITOR_DEMOS_ROOT = "demos"
DEFAULT_MAIN_PY = 'print("Hello, World!")\n'
# Self-contained demos copied from shipped `workspace/code/` (stdlib + machine/neopixel/time only).
# New accounts get a copy of each one in their own `code/` folder so the
# editor has something to show on first login. They're treated as
# starting points — users can edit/delete freely without affecting the
# shipped originals.
# Self-contained demos copied from static ``bundled-demos/demo/`` (stdlib + machine/neopixel/time only).
# New accounts get a copy under ``<user_root>/demos/`` (same level as ``code/``).
_CANONICAL_DEMO_FILENAMES = (
"pattern_rainbow_demo.py",
"pattern_twinkle_demo.py",
@@ -20,6 +21,8 @@ _CANONICAL_DEMO_FILENAMES = (
"adc_slider_demo.py",
"pin_demo.py",
"serial_demo.py",
"async_fetch_demo.py",
"aiohttp_fetch_demo.py",
)
@@ -33,17 +36,22 @@ def user_workspace_root(user_id: int, username: str, workspace_root: Path | None
return root / "users" / safe_workspace_leaf(username, user_id)
def _seed_canonical_demos_into_code(code_dir: Path) -> None:
"""Copy bundled demos into a user's `code/` if missing.
def _seed_canonical_demos(user_root: Path) -> None:
"""Copy bundled demos into ``demos/`` if missing.
Reads from `BUNDLED_DEMOS_DIR` (single source of truth, ships under
`src/static/bundled-demos/`), never from `workspace/`, so this works
even when `workspace/` is empty (gitignored runtime directory).
Reads from `BUNDLED_DEMOS_CODE_DIR` (``src/static/bundled-demos/demo/``).
Skips if the file already exists under ``demos/``, legacy ``code/<name>.py``,
or old ``code/demos/<name>.py``.
"""
src_root = config.BUNDLED_DEMOS_DIR.resolve()
src_root = _BUNDLED_DEMO_PY_DIR.resolve()
demos_dir = user_root / _EDITOR_DEMOS_ROOT
code_dir = user_root / "code"
demos_dir.mkdir(parents=True, exist_ok=True)
for filename in _CANONICAL_DEMO_FILENAMES:
dst = code_dir / filename
if dst.exists():
dst = demos_dir / filename
legacy_flat = code_dir / filename
legacy_nested = code_dir / "demos" / filename
if dst.exists() or legacy_flat.exists() or legacy_nested.exists():
continue
src = src_root / filename
if src.is_file():
@@ -51,9 +59,9 @@ def _seed_canonical_demos_into_code(code_dir: Path) -> None:
def ensure_default_code_main(user_root: Path) -> None:
"""Ensure code/ has main.py and the canonical NeoPixel demos.
"""Ensure ``code/main.py`` and canonical demos under top-level ``demos/``.
Demos are sourced from `BUNDLED_DEMOS_DIR` (the single committed home
Demos are sourced from `BUNDLED_DEMOS_CODE_DIR` (the single committed home
for sample scripts). Only files listed in `_CANONICAL_DEMO_FILENAMES`
get auto-seeded — the rest are available via the editor's "Reset
demos" button or a manual copy."""
@@ -62,30 +70,34 @@ def ensure_default_code_main(user_root: Path) -> None:
main_py = code_dir / "main.py"
if not main_py.exists():
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
_seed_canonical_demos_into_code(code_dir)
_seed_canonical_demos(user_root)
def seed_all_bundled_demos(user_root: Path) -> None:
"""Copy *every* file in `BUNDLED_DEMOS_DIR` into `<user_root>/code/`.
"""Copy *every* ``.py`` file in `BUNDLED_DEMOS_CODE_DIR` into ``<user_root>/demos/``.
Used at app startup to populate a fresh `workspace/code/` with the
full sample set so a no-auth dev install (`pipenv run dev` after
`git clone`) has something to play with. Existing files are not
overwritten — user edits are preserved.
Used at app startup to populate a fresh workspace with the full sample
set so a no-auth dev install (`pipenv run dev` after `git clone`) has
something to play with. Existing files are not overwritten — user edits
are preserved.
"""
code_dir = user_root / "code"
code_dir.mkdir(parents=True, exist_ok=True)
main_py = code_dir / "main.py"
if not main_py.exists():
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
src_root = config.BUNDLED_DEMOS_DIR.resolve()
demos_dir = user_root / _EDITOR_DEMOS_ROOT
demos_dir.mkdir(parents=True, exist_ok=True)
src_root = _BUNDLED_DEMO_PY_DIR.resolve()
if not src_root.is_dir():
return
for src in sorted(src_root.iterdir()):
if not src.is_file() or not src.name.endswith(".py"):
continue
dst = code_dir / src.name
if dst.exists():
dst = demos_dir / src.name
legacy_flat = code_dir / src.name
legacy_nested = code_dir / "demos" / src.name
if dst.exists() or legacy_flat.exists() or legacy_nested.exists():
continue
try:
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")