Compare commits
2 Commits
f204109a84
...
7d682cce8d
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d682cce8d | |||
| e4c811f51d |
12
.cursor/hooks.json
Normal file
12
.cursor/hooks.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"afterFileEdit": [
|
||||
{
|
||||
"command": ".cursor/hooks/dev-reload-touch.sh",
|
||||
"timeout": 5,
|
||||
"matcher": "Write|TabWrite"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
8
.cursor/hooks/dev-reload-touch.sh
Executable file
8
.cursor/hooks/dev-reload-touch.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Consume hook JSON input (not needed for this hook).
|
||||
cat >/dev/null || true
|
||||
|
||||
mkdir -p "src/static"
|
||||
date +%s%3N > "src/static/.reload-token"
|
||||
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.cursor/
|
||||
tests/
|
||||
agent-transcripts/
|
||||
data/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
10
.env.example
10
.env.example
@@ -9,10 +9,20 @@
|
||||
# --- User accounts (SQLite) ---
|
||||
# AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*)
|
||||
# AUTH_REGISTER_OPEN=true # allow POST /api/auth/register
|
||||
# AUTH_INVITE_ONLY=false # require invite token for registration
|
||||
# AUTH_DATABASE_PATH=./data/editor.db
|
||||
# AUTH_SESSION_DAYS=14
|
||||
# BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users
|
||||
# BOOTSTRAP_ADMIN_PASSWORD=change-me-in-production
|
||||
|
||||
# Optional invite email (used by POST /api/users/invites)
|
||||
# PUBLIC_BASE_URL=http://127.0.0.1:8080
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=mailer@example.com
|
||||
# SMTP_PASSWORD=app-password
|
||||
# SMTP_FROM=Python Editor <mailer@example.com>
|
||||
# SMTP_TLS=true
|
||||
|
||||
# Base URL for `pipenv run test-selenium` (app must be running separately)
|
||||
# SELENIUM_BASE_URL=http://127.0.0.1:8080
|
||||
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir pipenv
|
||||
|
||||
COPY Pipfile Pipfile.lock ./
|
||||
RUN pipenv install --system --deploy
|
||||
|
||||
COPY src ./src
|
||||
COPY workspace ./workspace
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "app:app", "--app-dir", "src", "--host", "0.0.0.0", "--port", "8080"]
|
||||
75
LED_TUTORIAL.md
Normal file
75
LED_TUTORIAL.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Python LED Tutorial (NeoPixel Focus)
|
||||
|
||||
This tutorial is for the browser editor's ESP32-style mocks:
|
||||
|
||||
- `machine.Pin`
|
||||
- `neopixel.NeoPixel`
|
||||
|
||||
Use `workspace/code/led_tutorial.py` while reading this guide.
|
||||
|
||||
## 1) Basic setup
|
||||
|
||||
```python
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 12)
|
||||
```
|
||||
|
||||
- `Pin(4)` means data pin 4 (matching common ESP32 examples).
|
||||
- `12` is the number of LEDs in the strip/ring.
|
||||
- `np` is your LED strip object.
|
||||
|
||||
## 2) Set one LED color
|
||||
|
||||
```python
|
||||
np[0] = (255, 0, 0) # red
|
||||
np.write()
|
||||
```
|
||||
|
||||
- Colors are `(red, green, blue)` from `0` to `255`.
|
||||
- Nothing updates visually until `np.write()`.
|
||||
|
||||
## 3) Fill all LEDs
|
||||
|
||||
```python
|
||||
np.fill((0, 0, 255)) # all blue
|
||||
np.write()
|
||||
```
|
||||
|
||||
## 4) Clear LEDs (turn off)
|
||||
|
||||
```python
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
```
|
||||
|
||||
## 5) Animate over time
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
for step in range(20):
|
||||
np.fill((step * 10, 0, 255 - step * 10))
|
||||
np.write()
|
||||
time.sleep(0.08)
|
||||
```
|
||||
|
||||
`time.sleep(...)` controls animation speed.
|
||||
|
||||
## 6) Moving pixel example
|
||||
|
||||
```python
|
||||
for i in range(len(np)):
|
||||
np.fill((0, 0, 0))
|
||||
np[i] = (255, 120, 0)
|
||||
np.write()
|
||||
time.sleep(0.06)
|
||||
```
|
||||
|
||||
## 7) Tips
|
||||
|
||||
- Keep color values in `0..255`.
|
||||
- Use helper functions for repeated color logic.
|
||||
- Start with short loops, then increase frames once behavior looks good.
|
||||
- If the simulator is closed, run your script again to show updates.
|
||||
1
Pipfile
1
Pipfile
@@ -8,6 +8,7 @@ pytest = "*"
|
||||
pytest-cov = "*"
|
||||
httpx = "*"
|
||||
selenium = "*"
|
||||
playwright = "*"
|
||||
|
||||
[packages]
|
||||
fastapi = "*"
|
||||
|
||||
90
Pipfile.lock
generated
90
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "f9de958b9982b3a30c107c2c02746aece031522dc429be84e4f9589c73404282"
|
||||
"sha256": "ce177325185c5a9e04401b9368766f419921dfc5f9de934820507dae79998082"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -586,6 +586,71 @@
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==7.13.5"
|
||||
},
|
||||
"greenlet": {
|
||||
"hashes": [
|
||||
"sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846",
|
||||
"sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4",
|
||||
"sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662",
|
||||
"sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce",
|
||||
"sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2",
|
||||
"sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588",
|
||||
"sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13",
|
||||
"sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e",
|
||||
"sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a",
|
||||
"sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3",
|
||||
"sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b",
|
||||
"sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033",
|
||||
"sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628",
|
||||
"sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136",
|
||||
"sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b",
|
||||
"sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d",
|
||||
"sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2",
|
||||
"sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb",
|
||||
"sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd",
|
||||
"sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b",
|
||||
"sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1",
|
||||
"sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16",
|
||||
"sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d",
|
||||
"sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106",
|
||||
"sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba",
|
||||
"sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c",
|
||||
"sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc",
|
||||
"sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7",
|
||||
"sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339",
|
||||
"sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b",
|
||||
"sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae",
|
||||
"sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8",
|
||||
"sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2",
|
||||
"sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5",
|
||||
"sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf",
|
||||
"sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f",
|
||||
"sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f",
|
||||
"sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2",
|
||||
"sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb",
|
||||
"sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082",
|
||||
"sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7",
|
||||
"sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0",
|
||||
"sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c",
|
||||
"sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853",
|
||||
"sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988",
|
||||
"sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3",
|
||||
"sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858",
|
||||
"sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37",
|
||||
"sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977",
|
||||
"sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4",
|
||||
"sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8",
|
||||
"sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86",
|
||||
"sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f",
|
||||
"sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112",
|
||||
"sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e",
|
||||
"sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2",
|
||||
"sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8",
|
||||
"sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243",
|
||||
"sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564"
|
||||
],
|
||||
"markers": "platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))",
|
||||
"version": "==3.5.0"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
|
||||
@@ -643,6 +708,21 @@
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==26.2"
|
||||
},
|
||||
"playwright": {
|
||||
"hashes": [
|
||||
"sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6",
|
||||
"sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119",
|
||||
"sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100",
|
||||
"sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9",
|
||||
"sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47",
|
||||
"sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e",
|
||||
"sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d",
|
||||
"sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.59.0"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
|
||||
@@ -651,6 +731,14 @@
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.6.0"
|
||||
},
|
||||
"pyee": {
|
||||
"hashes": [
|
||||
"sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8",
|
||||
"sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==13.0.1"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
|
||||
|
||||
90
README.md
90
README.md
@@ -1,6 +1,6 @@
|
||||
# python-editor
|
||||
|
||||
Browser-based Python editing: **FastAPI** serves static assets, stores workspace files, and optional **API key auth**. **Pyodide** runs your scripts and **Jedi** (inside Pyodide) powers completions — no server-side Python execution or Jedi.
|
||||
Browser-based Python editing: **FastAPI** serves static assets, stores workspace files, and optional **API key auth**. **Pyodide** runs your scripts and **Jedi** (inside Pyodide) powers completions and syntax diagnostics — no server-side Python execution or LSP process.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -48,8 +48,44 @@ If nothing is listening, the smoke test **skips** with a short message instead o
|
||||
|
||||
Open [http://localhost:8080](http://localhost:8080).
|
||||
|
||||
### Editor runtime controls
|
||||
|
||||
- `Run Python` runs the active open `.py` tab.
|
||||
- Enable `Run main.py` to always run `code/main.py` instead.
|
||||
- Pressing `Run Python` while a script is running will stop and restart with the selected target.
|
||||
- `LSP` badge in the header shows in-browser Jedi syntax status (`n/a`, `checking...`, `OK`, or issue count).
|
||||
|
||||
## Deploy with Docker
|
||||
|
||||
Build and run with Docker Compose:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
mkdir -p data
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open [http://localhost:8080](http://localhost:8080).
|
||||
|
||||
Notes:
|
||||
|
||||
- `workspace/` is mounted to `/app/workspace` so your code persists locally.
|
||||
- `data/` is mounted to `/app/data` for the SQLite auth DB.
|
||||
- In container mode, `WORKSPACE_ROOT` and `AUTH_DATABASE_PATH` are set by `docker-compose.yml`.
|
||||
|
||||
**User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` (if `AUTH_REGISTER_OPEN=true`) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET/POST/DELETE `/api/users`** to list, create, or remove accounts.
|
||||
|
||||
Email invite signup:
|
||||
|
||||
- Superusers can create invites via `POST /api/users/invites` with `{ "email": "...", "expires_days": 7 }`.
|
||||
- Response includes `invite_url`; if SMTP is configured the invite email is sent automatically.
|
||||
- Set `AUTH_INVITE_ONLY=true` to require invite tokens for all registrations.
|
||||
- Registration page accepts invite links like `/register?invite=<token>`.
|
||||
|
||||
When auth is enabled, file APIs use a per-user workspace under `WORKSPACE_ROOT/users/<username-id>/` for **isolated `code/`**. The `lib/` tree is shared and read-only for all users. When auth is disabled, the shared workspace root is used for everything.
|
||||
|
||||
Admins can open another user's workspace from the home page user management panel (links to `/editor?workspace_user_id=<id>`). Only superusers may use this override.
|
||||
|
||||
**API key** — If `EDITOR_API_KEY` is set, requests may use `Authorization: Bearer …` instead of a session (useful for automation). When `AUTH_ENABLED=true`, a valid session *or* API key is accepted.
|
||||
|
||||
The home page can store the API key in `sessionStorage` when you are not using cookie login, or use `?api_key=` on `/editor`.
|
||||
@@ -58,3 +94,55 @@ The home page can store the API key in `sessionStorage` when you are not using c
|
||||
|
||||
- `src/` — FastAPI app and static UI (`src/static/`)
|
||||
- `workspace/` — default tree: `code/` (editable), `lib/` (read-only via API)
|
||||
|
||||
## ESP32 / NeoPixel mock
|
||||
|
||||
The browser runtime now includes MicroPython-style mocks in `workspace/lib`:
|
||||
|
||||
- `machine.Pin`
|
||||
- `neopixel.NeoPixel`
|
||||
|
||||
Use them from scripts in `workspace/code` exactly like ESP32 examples:
|
||||
|
||||
```python
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 8)
|
||||
np[0] = (255, 0, 0)
|
||||
np.write()
|
||||
```
|
||||
|
||||
`write()` updates the NeoPixel simulator so you can verify behavior visually.
|
||||
|
||||
Simulator modes:
|
||||
|
||||
- Default: in-app LED strip/panel section under the editor.
|
||||
- `16x16 panel` checkbox: opens a dedicated popup with 16x16 serpentine mapping:
|
||||
- first LED at top-right
|
||||
- first row goes right -> left
|
||||
- rows zig-zag left/right.
|
||||
- The 16x16 popup closes automatically on **Stop** or when script execution finishes.
|
||||
|
||||
Tutorial files:
|
||||
|
||||
- `LED_TUTORIAL.md` - step-by-step NeoPixel tutorial
|
||||
- `workspace/code/led_tutorial.py` - runnable guided LED example
|
||||
- `workspace/code/led_patterns.py` - reusable pattern helpers (`rainbow_frame`, `chase_frame`, `twinkle_frame`)
|
||||
- `workspace/code/pattern_rainbow_demo.py` - rainbow animation demo
|
||||
- `workspace/code/pattern_chase_demo.py` - chase animation demo
|
||||
- `workspace/code/pattern_twinkle_demo.py` - twinkle animation demo
|
||||
- `workspace/code/panel16_utils.py` - helpers for 16x16 serpentine mapping
|
||||
- `workspace/code/panel16_rainbow_wave.py` - 16x16 rainbow wave
|
||||
- `workspace/code/panel16_bounce.py` - 16x16 bouncing pixel with trail
|
||||
- `workspace/code/panel16_matrix_rain.py` - 16x16 matrix rain effect
|
||||
|
||||
## Dev auto-reload hook
|
||||
|
||||
Project hook files are included in `.cursor/`:
|
||||
|
||||
- `.cursor/hooks.json`
|
||||
- `.cursor/hooks/dev-reload-touch.sh`
|
||||
|
||||
When files are edited through Cursor tools, the hook updates `src/static/.reload-token`.
|
||||
The editor (on localhost) polls that token and auto-reloads the browser when it changes.
|
||||
|
||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
python-editor:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- WORKSPACE_ROOT=/app/workspace
|
||||
- AUTH_DATABASE_PATH=/app/data/editor.db
|
||||
volumes:
|
||||
- ./workspace:/app/workspace
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -38,3 +38,20 @@ class AuthSession(Base):
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="sessions")
|
||||
|
||||
|
||||
class InviteToken(Base):
|
||||
__tablename__ = "invite_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(320), index=True)
|
||||
token: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
expires_at: Mapped[dt.datetime] = mapped_column(DateTime)
|
||||
used_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
invited_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
consumed_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Cookie, Depends, Header, HTTPException
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from editor_app.db.session import get_db
|
||||
from editor_app.db.models import User
|
||||
from editor_app import config
|
||||
from editor_app.services import accounts
|
||||
|
||||
|
||||
@@ -61,3 +64,33 @@ async def require_superuser(
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Superuser required")
|
||||
return user
|
||||
|
||||
|
||||
def _safe_workspace_leaf(user: User) -> str:
|
||||
base = re.sub(r"[^a-zA-Z0-9._-]+", "-", user.username).strip("-").lower() or "user"
|
||||
return f"{base}-{user.id}"
|
||||
|
||||
|
||||
def _seed_user_workspace(user_root: Path) -> None:
|
||||
(user_root / "code").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def get_workspace_root(
|
||||
user: User | None = Depends(get_current_user_optional),
|
||||
workspace_user_id: int | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Path:
|
||||
root = config.WORKSPACE_ROOT.resolve()
|
||||
if not accounts.auth_enabled() or user is None:
|
||||
return root
|
||||
target_user = user
|
||||
if workspace_user_id is not None:
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Superuser required for workspace override")
|
||||
lookup = accounts.get_user_by_id(db, int(workspace_user_id))
|
||||
if lookup is None:
|
||||
raise HTTPException(status_code=404, detail="Workspace user not found")
|
||||
target_user = lookup
|
||||
user_root = root / "users" / _safe_workspace_leaf(target_user)
|
||||
_seed_user_workspace(user_root)
|
||||
return user_root
|
||||
|
||||
@@ -33,7 +33,11 @@ def _clear_session_cookie(response: Response, request: Request) -> None:
|
||||
|
||||
@router.get("/status", response_model=AuthStatusResponse)
|
||||
async def auth_status() -> AuthStatusResponse:
|
||||
return AuthStatusResponse(auth_enabled=accounts.auth_enabled(), register_open=accounts.register_open())
|
||||
return AuthStatusResponse(
|
||||
auth_enabled=accounts.auth_enabled(),
|
||||
register_open=accounts.register_open(),
|
||||
invite_required=accounts.invite_required(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@@ -57,10 +61,17 @@ async def register(
|
||||
) -> UserPublic:
|
||||
if not accounts.auth_enabled():
|
||||
raise HTTPException(status_code=400, detail="Set AUTH_ENABLED=true to use accounts")
|
||||
if not accounts.register_open():
|
||||
if not accounts.register_open() and not body.invite_token:
|
||||
raise HTTPException(status_code=403, detail="Registration is disabled (AUTH_REGISTER_OPEN=false)")
|
||||
try:
|
||||
user = accounts.register_user(db, body.username, body.password)
|
||||
if accounts.invite_required():
|
||||
if not body.invite_token:
|
||||
raise HTTPException(status_code=403, detail="Invite token is required")
|
||||
user = accounts.register_user_with_invite(db, body.username, body.password, body.invite_token)
|
||||
elif body.invite_token:
|
||||
user = accounts.register_user_with_invite(db, body.username, body.password, body.invite_token)
|
||||
else:
|
||||
user = accounts.register_user(db, body.username, body.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return UserPublic.model_validate(user)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from editor_app.deps import get_workspace_root
|
||||
from editor_app.models import FileContent, FolderOperation, MoveFileRequest
|
||||
from editor_app.services import filesystem
|
||||
|
||||
@@ -7,50 +10,51 @@ router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
async def list_files(path: str = ""):
|
||||
files = filesystem.list_files(path)
|
||||
async def list_files(path: str = "", workspace_root: Path = Depends(get_workspace_root)):
|
||||
files = filesystem.list_files(path, workspace_root=workspace_root)
|
||||
return {"files": files}
|
||||
|
||||
|
||||
@router.get("/workspace/py-sources")
|
||||
async def workspace_python_sources():
|
||||
return {"files": filesystem.collect_python_sources()}
|
||||
async def workspace_python_sources(workspace_root: Path = Depends(get_workspace_root)):
|
||||
return {"files": filesystem.collect_python_sources(workspace_root=workspace_root)}
|
||||
|
||||
|
||||
@router.get("/file/{file_path:path}")
|
||||
async def read_file(file_path: str):
|
||||
content, filename = filesystem.read_text_file(file_path)
|
||||
async def read_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)):
|
||||
content, filename = filesystem.read_text_file(file_path, workspace_root=workspace_root)
|
||||
return {"content": content, "filename": filename}
|
||||
|
||||
|
||||
@router.post("/file/{file_path:path}")
|
||||
async def save_file(file_path: str, file_data: FileContent):
|
||||
filename = filesystem.save_text_file(file_path, file_data.content)
|
||||
async def save_file(file_path: str, file_data: FileContent, workspace_root: Path = Depends(get_workspace_root)):
|
||||
filename = filesystem.save_text_file(file_path, file_data.content, workspace_root=workspace_root)
|
||||
return {"message": "File saved successfully", "filename": filename}
|
||||
|
||||
|
||||
@router.post("/file-move")
|
||||
async def move_file(move_data: MoveFileRequest):
|
||||
async def move_file(move_data: MoveFileRequest, workspace_root: Path = Depends(get_workspace_root)):
|
||||
new_path, moved_type = filesystem.move_path(
|
||||
source_path=move_data.source_path,
|
||||
destination_folder=move_data.destination_folder,
|
||||
workspace_root=workspace_root,
|
||||
)
|
||||
return {"message": "Path moved successfully", "new_path": new_path, "moved_type": moved_type}
|
||||
|
||||
|
||||
@router.delete("/file/{file_path:path}")
|
||||
async def delete_file(file_path: str):
|
||||
filesystem.delete_file(file_path)
|
||||
async def delete_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)):
|
||||
filesystem.delete_file(file_path, workspace_root=workspace_root)
|
||||
return {"message": "File deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/folder/new/{folder_path:path}")
|
||||
async def create_folder(folder_path: str, folder_data: FolderOperation):
|
||||
folder_name = filesystem.create_folder(folder_path)
|
||||
async def create_folder(folder_path: str, folder_data: FolderOperation, workspace_root: Path = Depends(get_workspace_root)):
|
||||
folder_name = filesystem.create_folder(folder_path, workspace_root=workspace_root)
|
||||
return {"message": "Folder created successfully", "folder": folder_name}
|
||||
|
||||
|
||||
@router.delete("/folder/{folder_path:path}")
|
||||
async def delete_folder(folder_path: str):
|
||||
filesystem.delete_folder(folder_path)
|
||||
async def delete_folder(folder_path: str, workspace_root: Path = Depends(get_workspace_root)):
|
||||
filesystem.delete_folder(folder_path, workspace_root=workspace_root)
|
||||
return {"message": "Folder deleted successfully"}
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
||||
from editor_app.db.session import get_db
|
||||
from editor_app.db.models import User
|
||||
from editor_app.deps import require_superuser
|
||||
from editor_app.schemas.users import UserCreateAdmin, UserPublic
|
||||
from editor_app.schemas.users import InviteCreateRequest, InviteCreateResponse, UserCreateAdmin, UserPublic
|
||||
from editor_app.services import accounts
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
@@ -48,3 +48,26 @@ async def delete_user_admin(
|
||||
if not accounts.delete_user(db, user_id):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"message": "User deleted"}
|
||||
|
||||
|
||||
@router.post("/invites", response_model=InviteCreateResponse)
|
||||
async def create_invite_admin(
|
||||
body: InviteCreateRequest,
|
||||
admin: User = Depends(require_superuser),
|
||||
db: Session = Depends(get_db),
|
||||
) -> InviteCreateResponse:
|
||||
email = (body.email or "").strip().lower()
|
||||
invite = accounts.create_invite(
|
||||
db,
|
||||
email,
|
||||
invited_by_user_id=admin.id,
|
||||
expires_days=body.expires_days,
|
||||
)
|
||||
invite_url = accounts.build_invite_url(invite.token)
|
||||
delivered = False
|
||||
if email:
|
||||
try:
|
||||
delivered = accounts.send_invite_email(email, invite_url)
|
||||
except Exception:
|
||||
delivered = False
|
||||
return InviteCreateResponse(email=invite.email, invite_url=invite_url, delivered=delivered)
|
||||
|
||||
@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
invite_token: str | None = Field(default=None, min_length=8, max_length=256)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
@@ -44,3 +45,27 @@ class UserCreateAdmin(BaseModel):
|
||||
class AuthStatusResponse(BaseModel):
|
||||
auth_enabled: bool
|
||||
register_open: bool
|
||||
invite_required: bool = False
|
||||
|
||||
|
||||
class InviteCreateRequest(BaseModel):
|
||||
email: str | None = Field(default=None, max_length=320)
|
||||
expires_days: int = Field(default=7, ge=1, le=30)
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def email_sane(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
s = v.strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
if "@" not in s or "." not in s.split("@")[-1]:
|
||||
raise ValueError("Invalid email address")
|
||||
return s
|
||||
|
||||
|
||||
class InviteCreateResponse(BaseModel):
|
||||
email: str
|
||||
invite_url: str
|
||||
delivered: bool
|
||||
|
||||
@@ -3,13 +3,15 @@ from __future__ import annotations
|
||||
import datetime as dt
|
||||
import os
|
||||
import secrets
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from editor_app.db.models import AuthSession, User
|
||||
from editor_app.db.models import AuthSession, InviteToken, User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -75,6 +77,20 @@ def register_user(db: Session, username: str, password: str) -> User:
|
||||
return create_user(db, username, password, is_superuser=first)
|
||||
|
||||
|
||||
def register_user_with_invite(db: Session, username: str, password: str, invite_token: str) -> User:
|
||||
invite = get_valid_invite(db, invite_token)
|
||||
if invite is None:
|
||||
raise ValueError("Invite is invalid or expired")
|
||||
if get_user_by_username(db, username):
|
||||
raise ValueError("Username already taken")
|
||||
user = create_user(db, username, password, is_superuser=False)
|
||||
invite.used_at = _utc_naive()
|
||||
invite.consumed_by_user_id = user.id
|
||||
db.add(invite)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def authenticate(db: Session, username: str, password: str) -> User | None:
|
||||
user = get_user_by_username(db, username.strip())
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
@@ -126,3 +142,68 @@ def delete_user(db: Session, user_id: int) -> bool:
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def invite_required() -> bool:
|
||||
return os.environ.get("AUTH_INVITE_ONLY", "false").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def create_invite(db: Session, email: str, invited_by_user_id: int | None = None, expires_days: int = 7) -> InviteToken:
|
||||
token = secrets.token_urlsafe(36)
|
||||
expires = _utc_naive() + dt.timedelta(days=max(1, min(30, int(expires_days))))
|
||||
row = InviteToken(
|
||||
email=email.strip().lower(),
|
||||
token=token,
|
||||
expires_at=expires,
|
||||
invited_by_user_id=invited_by_user_id,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def get_valid_invite(db: Session, token: str | None) -> InviteToken | None:
|
||||
if not token:
|
||||
return None
|
||||
row = db.scalars(select(InviteToken).where(InviteToken.token == token.strip())).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
if row.used_at is not None:
|
||||
return None
|
||||
if row.expires_at < _utc_naive():
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def build_invite_url(token: str) -> str:
|
||||
base = (os.environ.get("PUBLIC_BASE_URL") or "http://127.0.0.1:8080").rstrip("/")
|
||||
return f"{base}/register?invite={token}"
|
||||
|
||||
|
||||
def send_invite_email(email: str, invite_url: str) -> bool:
|
||||
host = (os.environ.get("SMTP_HOST") or "").strip()
|
||||
if not host:
|
||||
return False
|
||||
port = int((os.environ.get("SMTP_PORT") or "587").strip())
|
||||
user = (os.environ.get("SMTP_USER") or "").strip()
|
||||
password = os.environ.get("SMTP_PASSWORD") or ""
|
||||
sender = (os.environ.get("SMTP_FROM") or user or "noreply@python-editor.local").strip()
|
||||
use_tls = (os.environ.get("SMTP_TLS", "true").strip().lower() in ("1", "true", "yes", "on"))
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "Your Python Editor invite"
|
||||
msg["From"] = sender
|
||||
msg["To"] = email
|
||||
msg.set_content(
|
||||
"You have been invited to Python Editor.\n\n"
|
||||
f"Use this link to sign up:\n{invite_url}\n\n"
|
||||
"If you did not expect this invite, you can ignore this message.\n"
|
||||
)
|
||||
with smtplib.SMTP(host, port, timeout=10) as smtp:
|
||||
if use_tls:
|
||||
smtp.starttls()
|
||||
if user:
|
||||
smtp.login(user, password)
|
||||
smtp.send_message(msg)
|
||||
return True
|
||||
|
||||
@@ -10,6 +10,14 @@ LIB_DIR_NAME = "lib"
|
||||
WRITABLE_ROOTS = {"code"}
|
||||
|
||||
|
||||
def _workspace_root(workspace_root: Path | None = None) -> Path:
|
||||
return (workspace_root or config.WORKSPACE_ROOT).resolve()
|
||||
|
||||
|
||||
def _shared_lib_root() -> Path:
|
||||
return (config.WORKSPACE_ROOT.resolve() / LIB_DIR_NAME).resolve()
|
||||
|
||||
|
||||
def normalize_relative_path(relative_path: str) -> str:
|
||||
cleaned = (relative_path or "").strip().lstrip("/")
|
||||
if not cleaned:
|
||||
@@ -22,33 +30,48 @@ def normalize_relative_path(relative_path: str) -> str:
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def resolve_workspace_path(relative_path: str) -> Path:
|
||||
def resolve_workspace_path(relative_path: str, workspace_root: Path | None = None) -> Path:
|
||||
relative_path = normalize_relative_path(relative_path)
|
||||
target_path = (config.WORKSPACE_ROOT / relative_path).resolve()
|
||||
root = _workspace_root(workspace_root)
|
||||
if relative_path == LIB_DIR_NAME or relative_path.startswith(f"{LIB_DIR_NAME}/"):
|
||||
suffix = relative_path[len(LIB_DIR_NAME) :].lstrip("/")
|
||||
target_path = (_shared_lib_root() / suffix).resolve()
|
||||
try:
|
||||
target_path.relative_to(_shared_lib_root())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Path escapes shared lib") from exc
|
||||
return target_path
|
||||
target_path = (root / relative_path).resolve()
|
||||
try:
|
||||
target_path.relative_to(config.WORKSPACE_ROOT.resolve())
|
||||
target_path.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Path escapes workspace") from exc
|
||||
return target_path
|
||||
|
||||
|
||||
def _is_path_in_lib(target_path: Path) -> bool:
|
||||
workspace = config.WORKSPACE_ROOT.resolve()
|
||||
def _is_path_in_lib(target_path: Path, workspace_root: Path | None = None) -> bool:
|
||||
workspace = _workspace_root(workspace_root)
|
||||
lib_root = (workspace / LIB_DIR_NAME).resolve()
|
||||
shared_lib_root = _shared_lib_root()
|
||||
try:
|
||||
target_path.resolve().relative_to(lib_root)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
target_path.resolve().relative_to(shared_lib_root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_not_lib_path(target_path: Path) -> None:
|
||||
if _is_path_in_lib(target_path):
|
||||
def _ensure_not_lib_path(target_path: Path, workspace_root: Path | None = None) -> None:
|
||||
if _is_path_in_lib(target_path, workspace_root):
|
||||
raise HTTPException(status_code=403, detail="lib is read-only")
|
||||
|
||||
|
||||
def _is_writable_path(target_path: Path) -> bool:
|
||||
workspace = config.WORKSPACE_ROOT.resolve()
|
||||
def _is_writable_path(target_path: Path, workspace_root: Path | None = None) -> bool:
|
||||
workspace = _workspace_root(workspace_root)
|
||||
resolved = target_path.resolve()
|
||||
try:
|
||||
relative = resolved.relative_to(workspace)
|
||||
@@ -59,17 +82,18 @@ def _is_writable_path(target_path: Path) -> bool:
|
||||
return relative.parts[0] in WRITABLE_ROOTS
|
||||
|
||||
|
||||
def _ensure_writable_path(target_path: Path) -> None:
|
||||
if not _is_writable_path(target_path):
|
||||
def _ensure_writable_path(target_path: Path, workspace_root: Path | None = 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)",
|
||||
)
|
||||
|
||||
|
||||
def list_files(path: str = "") -> list[FileInfo]:
|
||||
def list_files(path: str = "", workspace_root: Path | None = None) -> list[FileInfo]:
|
||||
path = normalize_relative_path(path)
|
||||
target_path = config.WORKSPACE_ROOT / path if path else config.WORKSPACE_ROOT
|
||||
root = _workspace_root(workspace_root)
|
||||
target_path = resolve_workspace_path(path, root) if path else root
|
||||
if not target_path.exists() or not target_path.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Directory not found")
|
||||
|
||||
@@ -77,6 +101,8 @@ def list_files(path: str = "") -> list[FileInfo]:
|
||||
for item in sorted(target_path.iterdir()):
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
if not path and item.name == "users":
|
||||
continue
|
||||
files.append(
|
||||
FileInfo(
|
||||
name=item.name,
|
||||
@@ -84,11 +110,15 @@ def list_files(path: str = "") -> list[FileInfo]:
|
||||
size=item.stat().st_size if item.is_file() else None,
|
||||
)
|
||||
)
|
||||
if not path:
|
||||
shared_lib = _shared_lib_root()
|
||||
if shared_lib.exists() and not any(f.name == LIB_DIR_NAME for f in files):
|
||||
files.append(FileInfo(name=LIB_DIR_NAME, is_directory=True, size=None))
|
||||
return files
|
||||
|
||||
|
||||
def read_text_file(file_path: str) -> tuple[str, str]:
|
||||
target_path = resolve_workspace_path(file_path)
|
||||
def read_text_file(file_path: str, workspace_root: Path | None = None) -> tuple[str, str]:
|
||||
target_path = resolve_workspace_path(file_path, workspace_root)
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if target_path.is_dir():
|
||||
@@ -100,19 +130,19 @@ def read_text_file(file_path: str) -> tuple[str, str]:
|
||||
return content, target_path.name
|
||||
|
||||
|
||||
def save_text_file(file_path: str, content: str) -> str:
|
||||
target_path = resolve_workspace_path(file_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def save_text_file(file_path: str, content: str, workspace_root: Path | None = None) -> str:
|
||||
target_path = resolve_workspace_path(file_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
return target_path.name
|
||||
|
||||
|
||||
def delete_file(file_path: str) -> None:
|
||||
target_path = resolve_workspace_path(file_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def delete_file(file_path: str, workspace_root: Path | None = None) -> None:
|
||||
target_path = resolve_workspace_path(file_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if target_path.is_dir():
|
||||
@@ -120,20 +150,21 @@ def delete_file(file_path: str) -> None:
|
||||
target_path.unlink()
|
||||
|
||||
|
||||
def move_path(source_path: str, destination_folder: str) -> tuple[str, str]:
|
||||
source = resolve_workspace_path(source_path)
|
||||
_ensure_not_lib_path(source)
|
||||
_ensure_writable_path(source)
|
||||
def move_path(source_path: str, destination_folder: str, workspace_root: Path | None = None) -> tuple[str, str]:
|
||||
root = _workspace_root(workspace_root)
|
||||
source = resolve_workspace_path(source_path, root)
|
||||
_ensure_not_lib_path(source, root)
|
||||
_ensure_writable_path(source, root)
|
||||
if not source.exists():
|
||||
raise HTTPException(status_code=404, detail="Source path not found")
|
||||
|
||||
destination_dir = (
|
||||
resolve_workspace_path(destination_folder)
|
||||
resolve_workspace_path(destination_folder, root)
|
||||
if destination_folder
|
||||
else config.WORKSPACE_ROOT
|
||||
else root
|
||||
)
|
||||
_ensure_not_lib_path(destination_dir)
|
||||
_ensure_writable_path(destination_dir)
|
||||
_ensure_not_lib_path(destination_dir, root)
|
||||
_ensure_writable_path(destination_dir, root)
|
||||
if not destination_dir.exists() or not destination_dir.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Destination folder not found")
|
||||
|
||||
@@ -157,23 +188,23 @@ def move_path(source_path: str, destination_folder: str) -> tuple[str, str]:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.rename(destination)
|
||||
moved_type = "folder" if destination.is_dir() else "file"
|
||||
return str(destination.relative_to(config.WORKSPACE_ROOT)), moved_type
|
||||
return str(destination.relative_to(root)), moved_type
|
||||
|
||||
|
||||
def create_folder(folder_path: str) -> str:
|
||||
target_path = resolve_workspace_path(folder_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def create_folder(folder_path: str, workspace_root: Path | None = None) -> str:
|
||||
target_path = resolve_workspace_path(folder_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
if target_path.exists():
|
||||
raise HTTPException(status_code=400, detail="Folder already exists")
|
||||
target_path.mkdir(parents=True, exist_ok=False)
|
||||
return target_path.name
|
||||
|
||||
|
||||
def delete_folder(folder_path: str) -> None:
|
||||
target_path = resolve_workspace_path(folder_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def delete_folder(folder_path: str, workspace_root: Path | None = None) -> None:
|
||||
target_path = resolve_workspace_path(folder_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
if not target_path.is_dir():
|
||||
@@ -181,10 +212,10 @@ def delete_folder(folder_path: str) -> None:
|
||||
shutil.rmtree(target_path)
|
||||
|
||||
|
||||
def collect_python_sources() -> dict[str, str]:
|
||||
def collect_python_sources(workspace_root: Path | None = None) -> dict[str, str]:
|
||||
"""Return all UTF-8 .py files under the workspace for browser-side Pyodide sync."""
|
||||
result: dict[str, str] = {}
|
||||
workspace = config.WORKSPACE_ROOT.resolve()
|
||||
workspace = _workspace_root(workspace_root)
|
||||
if not workspace.exists():
|
||||
return result
|
||||
for path in workspace.rglob("*.py"):
|
||||
@@ -199,4 +230,18 @@ def collect_python_sources() -> dict[str, str]:
|
||||
result[key] = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
shared_lib = _shared_lib_root()
|
||||
if shared_lib.exists() and shared_lib.is_dir() and shared_lib != (workspace / LIB_DIR_NAME).resolve():
|
||||
for path in shared_lib.rglob("*.py"):
|
||||
try:
|
||||
rel = path.relative_to(shared_lib)
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
continue
|
||||
try:
|
||||
key = str(Path(LIB_DIR_NAME) / rel).replace("\\", "/")
|
||||
result[key] = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
return result
|
||||
|
||||
1
src/static/.reload-token
Normal file
1
src/static/.reload-token
Normal file
@@ -0,0 +1 @@
|
||||
1777623664358
|
||||
@@ -52,6 +52,59 @@
|
||||
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
|
||||
.nav span { color: #94a3b8; font-size: 0.9rem; }
|
||||
.hidden { display: none !important; }
|
||||
.invite-panel {
|
||||
margin: 1rem 0;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 10px;
|
||||
background: rgba(30, 41, 59, 0.45);
|
||||
}
|
||||
.invite-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.invite-row input[type="email"] {
|
||||
flex: 1 1 260px;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.invite-row button {
|
||||
margin: 0;
|
||||
}
|
||||
.invite-result {
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.users-panel {
|
||||
margin: 1rem 0;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 10px;
|
||||
background: rgba(30, 41, 59, 0.45);
|
||||
}
|
||||
.users-list {
|
||||
margin-top: 0.5rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.user-row a {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(147, 197, 253, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -70,6 +123,20 @@
|
||||
<input id="api-key" type="password" autocomplete="off" placeholder="Leave blank if not used" />
|
||||
<p class="note">The key is kept in <code>sessionStorage</code>. You can also use <code>?api_key=…</code> on the editor URL.</p>
|
||||
</div>
|
||||
<section id="invite-panel" class="invite-panel hidden">
|
||||
<strong>Admin invites</strong>
|
||||
<p class="note" style="margin-top:0.4rem">Create an email invite link for signup.</p>
|
||||
<div class="invite-row">
|
||||
<input id="invite-email" type="email" placeholder="new.user@example.com" autocomplete="email" />
|
||||
<button type="button" class="btn btn-primary" id="invite-create-btn">Create invite</button>
|
||||
<button type="button" class="btn btn-ghost" id="invite-link-btn">Create link only</button>
|
||||
</div>
|
||||
<div id="invite-result" class="invite-result"></div>
|
||||
</section>
|
||||
<section id="users-panel" class="users-panel hidden">
|
||||
<strong>User management</strong>
|
||||
<div id="users-list" class="users-list"></div>
|
||||
</section>
|
||||
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
|
||||
</main>
|
||||
<script>
|
||||
@@ -85,11 +152,15 @@
|
||||
const outEl = document.getElementById('btn-logout');
|
||||
const greet = document.getElementById('auth-greeting');
|
||||
const optionalKey = document.getElementById('optional-api-key');
|
||||
const invitePanel = document.getElementById('invite-panel');
|
||||
const usersPanel = document.getElementById('users-panel');
|
||||
if (!status.auth_enabled) {
|
||||
loginEl.classList.add('hidden');
|
||||
regEl.classList.add('hidden');
|
||||
outEl.classList.add('hidden');
|
||||
greet.classList.add('hidden');
|
||||
if (invitePanel) invitePanel.classList.add('hidden');
|
||||
if (usersPanel) usersPanel.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
loginEl.classList.remove('hidden');
|
||||
@@ -105,9 +176,50 @@
|
||||
regEl.classList.add('hidden');
|
||||
outEl.classList.remove('hidden');
|
||||
if (optionalKey) optionalKey.classList.add('hidden');
|
||||
if (invitePanel) {
|
||||
if (j.user && j.user.is_superuser) {
|
||||
invitePanel.classList.remove('hidden');
|
||||
if (usersPanel) usersPanel.classList.remove('hidden');
|
||||
await refreshUsersList();
|
||||
} else {
|
||||
invitePanel.classList.add('hidden');
|
||||
if (usersPanel) usersPanel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
outEl.classList.add('hidden');
|
||||
greet.classList.add('hidden');
|
||||
if (invitePanel) invitePanel.classList.add('hidden');
|
||||
if (usersPanel) usersPanel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsersList() {
|
||||
const usersList = document.getElementById('users-list');
|
||||
if (!usersList) return;
|
||||
usersList.textContent = 'Loading users...';
|
||||
try {
|
||||
const res = await fetch('/api/users', { credentials: 'include' });
|
||||
const users = await res.json().catch(() => []);
|
||||
if (!res.ok) {
|
||||
usersList.textContent = 'Unable to load users.';
|
||||
return;
|
||||
}
|
||||
usersList.innerHTML = '';
|
||||
for (const user of users) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'user-row';
|
||||
const name = document.createElement('span');
|
||||
name.textContent = `${user.username}${user.is_superuser ? ' (admin)' : ''}`;
|
||||
const link = document.createElement('a');
|
||||
link.href = `/editor?workspace_user_id=${encodeURIComponent(String(user.id))}`;
|
||||
link.textContent = 'Open workspace';
|
||||
row.appendChild(name);
|
||||
row.appendChild(link);
|
||||
usersList.appendChild(row);
|
||||
}
|
||||
} catch (_err) {
|
||||
usersList.textContent = 'Unable to load users.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +249,54 @@
|
||||
}
|
||||
} catch (_e) {}
|
||||
});
|
||||
|
||||
document.getElementById('invite-create-btn').addEventListener('click', async () => {
|
||||
const emailInput = document.getElementById('invite-email');
|
||||
const result = document.getElementById('invite-result');
|
||||
const email = (emailInput.value || '').trim();
|
||||
result.textContent = '';
|
||||
if (!email) {
|
||||
result.textContent = 'Enter an email address first.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/users/invites', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, expires_days: 7 })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
result.textContent = body.detail || res.statusText || 'Failed to create invite';
|
||||
return;
|
||||
}
|
||||
const delivered = body.delivered ? 'Email sent.' : 'Email not sent (SMTP not configured), copy link manually.';
|
||||
result.innerHTML = `${delivered}<br><a href="${body.invite_url}" style="color:#93c5fd">${body.invite_url}</a>`;
|
||||
} catch (err) {
|
||||
result.textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
document.getElementById('invite-link-btn').addEventListener('click', async () => {
|
||||
const result = document.getElementById('invite-result');
|
||||
result.textContent = '';
|
||||
try {
|
||||
const res = await fetch('/api/users/invites', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: null, expires_days: 7 })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
result.textContent = body.detail || res.statusText || 'Failed to create invite link';
|
||||
return;
|
||||
}
|
||||
result.innerHTML = `Invite link created.<br><a href="${body.invite_url}" style="color:#93c5fd">${body.invite_url}</a>`;
|
||||
} catch (err) {
|
||||
result.textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
refreshAuthNav();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Python Editor</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/static/styles.css?v=6">
|
||||
<link rel="stylesheet" href="/static/styles.css?v=10">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -29,6 +29,8 @@
|
||||
<div class="file-info">
|
||||
<span id="save-status" class="save-status"></span>
|
||||
<span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span>
|
||||
<span id="lsp-status" class="runtime-hint" title="Jedi in-browser diagnostics">LSP: n/a</span>
|
||||
<span id="workspace-badge" class="runtime-hint hidden"></span>
|
||||
</div>
|
||||
<div class="mode-toggle">
|
||||
<a id="home-btn" class="mode-btn active" href="/">Home</a>
|
||||
@@ -36,9 +38,14 @@
|
||||
<div class="editor-actions">
|
||||
<button id="run-btn" disabled>Run Python</button>
|
||||
<button id="stop-btn" disabled>Stop</button>
|
||||
<select id="run-file-select" title="Script to run">
|
||||
<option value="">Run active file</option>
|
||||
</select>
|
||||
<label for="run-main-checkbox" class="run-main-toggle">
|
||||
<input type="checkbox" id="run-main-checkbox" />
|
||||
Run `main.py`
|
||||
</label>
|
||||
<label for="panel-16x16-checkbox" class="run-main-toggle">
|
||||
<input type="checkbox" id="panel-16x16-checkbox" />
|
||||
16x16 panel
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +56,19 @@
|
||||
<div id="completion-dropdown" class="completion-dropdown"></div>
|
||||
</div>
|
||||
|
||||
<section id="led-sim-panel" class="led-sim-panel hidden" aria-label="NeoPixel Simulator">
|
||||
<div class="led-sim-header">
|
||||
<h3>NeoPixel Simulator</h3>
|
||||
<div class="led-sim-actions">
|
||||
<button id="led-run-btn" type="button">Run</button>
|
||||
<button id="led-stop-btn" type="button">Stop</button>
|
||||
<button id="led-close-btn" type="button" aria-label="Close simulator">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="led-meta" class="led-meta">Waiting for neopixel.write()...</div>
|
||||
<div id="led-grid" class="led-grid"></div>
|
||||
</section>
|
||||
|
||||
<div class="console-container">
|
||||
<div class="console-header">Console Output</div>
|
||||
<pre id="console-output" class="console-output"></pre>
|
||||
@@ -67,6 +87,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/script.js?v=10"></script>
|
||||
<script type="module" src="/static/script.js?v=23"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -77,6 +77,38 @@ json.dumps(out)
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'diagnostics') {
|
||||
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
|
||||
const vpath = `/workspace/${rel}`;
|
||||
p.globals.set('__diag_code', String(payload.content ?? ''));
|
||||
p.globals.set('__diag_path', vpath);
|
||||
p.globals.set('__diag_extra_json', JSON.stringify(payload.extra_files || {}));
|
||||
const raw = p.runPython(`
|
||||
import json, os
|
||||
import jedi
|
||||
|
||||
extra = json.loads(__diag_extra_json)
|
||||
os.makedirs("/workspace", exist_ok=True)
|
||||
for rel_path, body in extra.items():
|
||||
rel_path = str(rel_path).lstrip("/")
|
||||
full = os.path.join("/workspace", rel_path)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "w", encoding="utf-8") as fh:
|
||||
fh.write(str(body))
|
||||
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
|
||||
with open(__diag_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(__diag_code)
|
||||
proj = jedi.Project("/workspace")
|
||||
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
|
||||
errs = s.get_syntax_errors()
|
||||
out = [{"line": e.line, "column": e.column, "message": str(e.get_message())} for e in errs]
|
||||
json.dumps(out)
|
||||
`);
|
||||
const diagnostics = JSON.parse(String(raw));
|
||||
self.postMessage({ id, type: 'diagnostics', ok: true, diagnostics });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'run') {
|
||||
const files = payload.files && typeof payload.files === 'object' ? payload.files : {};
|
||||
const mainRel = String(payload.mainPath || '').replace(/^\/+/, '');
|
||||
@@ -97,7 +129,7 @@ for rel, body in files.items():
|
||||
with open(full, "w", encoding="utf-8") as fh:
|
||||
fh.write(str(body))
|
||||
|
||||
for entry in ("/workspace/lib", "/workspace"):
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
|
||||
|
||||
@@ -61,11 +61,16 @@
|
||||
<input id="username" name="username" autocomplete="username" required />
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password" autocomplete="new-password" required />
|
||||
<input id="invite-token" type="hidden" name="invite_token" />
|
||||
<button type="submit" id="submit">Register</button>
|
||||
</form>
|
||||
<p><a href="/login">Sign in</a> · <a href="/">Home</a></p>
|
||||
</main>
|
||||
<script>
|
||||
const inviteToken = new URLSearchParams(window.location.search).get("invite") || "";
|
||||
const inviteInput = document.getElementById("invite-token");
|
||||
if (inviteInput) inviteInput.value = inviteToken;
|
||||
|
||||
(async function checkStatus() {
|
||||
try {
|
||||
const r = await fetch("/api/auth/status");
|
||||
@@ -73,9 +78,12 @@
|
||||
if (!s.auth_enabled) {
|
||||
document.getElementById("err").textContent = "Registration is disabled (AUTH_ENABLED is not set).";
|
||||
document.getElementById("form").style.display = "none";
|
||||
} else if (!s.register_open) {
|
||||
} else if (!s.register_open && !inviteToken) {
|
||||
document.getElementById("err").textContent = "Public registration is closed. Ask an administrator.";
|
||||
document.getElementById("form").style.display = "none";
|
||||
} else if (s.invite_required && !inviteToken) {
|
||||
document.getElementById("err").textContent = "This server requires an invite link to register.";
|
||||
document.getElementById("form").style.display = "none";
|
||||
}
|
||||
} catch (_e) {}
|
||||
})();
|
||||
@@ -90,6 +98,7 @@
|
||||
const body = {
|
||||
username: document.getElementById("username").value.trim(),
|
||||
password: document.getElementById("password").value,
|
||||
invite_token: inviteToken || null,
|
||||
};
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { EditorView, basicSetup } from "/static/codemirror.bundle.mjs";
|
||||
import { EditorView, basicSetup } from "https://esm.sh/codemirror";
|
||||
import { Compartment } from "https://esm.sh/@codemirror/state";
|
||||
import { python } from "https://esm.sh/@codemirror/lang-python";
|
||||
|
||||
class TextEditor {
|
||||
constructor() {
|
||||
@@ -7,6 +9,7 @@ class TextEditor {
|
||||
this.pyWorkerMsgId = 0;
|
||||
this.pyWorkerHandlers = new Map();
|
||||
this.pyodideInited = false;
|
||||
this.workerWarmupPromise = null;
|
||||
this.pyRunGeneration = 0;
|
||||
this.editor = null;
|
||||
this.currentFilePath = null;
|
||||
@@ -26,31 +29,91 @@ class TextEditor {
|
||||
this.completionIndex = 0;
|
||||
this.completionOpen = false;
|
||||
this.completionRequestId = 0;
|
||||
this.diagnosticsRequestId = 0;
|
||||
this.diagnosticsTimer = null;
|
||||
this.draggedItemPath = null;
|
||||
this.draggedItemIsDirectory = false;
|
||||
this.dragHoverExpandTimer = null;
|
||||
this.dragHoverTargetPath = null;
|
||||
this.savedSession = null;
|
||||
this.languageCompartment = new Compartment();
|
||||
this.autoCompleteTimer = null;
|
||||
this.ledSimWindow = null;
|
||||
this.ledPanelDismissed = false;
|
||||
this.lastLedFrame = null;
|
||||
this.ledPanelWindow = null;
|
||||
this.workspaceUserId = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
try {
|
||||
const fromQuery = new URLSearchParams(window.location.search).get('api_key');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromQuery = params.get('api_key');
|
||||
const workspaceUserId = params.get('workspace_user_id');
|
||||
if (fromQuery) {
|
||||
sessionStorage.setItem('python-editor.api_key', fromQuery);
|
||||
}
|
||||
if (workspaceUserId && /^\d+$/.test(workspaceUserId)) {
|
||||
this.workspaceUserId = workspaceUserId;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore query / storage failures.
|
||||
}
|
||||
this.loadSessionState();
|
||||
this.setupEditor();
|
||||
this.setupEventListeners();
|
||||
this.setupDevAutoReload();
|
||||
this.updateRunButtonState();
|
||||
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
|
||||
this.updateWorkspaceBanner();
|
||||
this.prewarmPyWorker();
|
||||
this.loadInitialDirectoryState().then(() => this.restoreSessionTabs());
|
||||
}
|
||||
|
||||
updateWorkspaceBanner() {
|
||||
const badge = document.getElementById('workspace-badge');
|
||||
if (!badge) return;
|
||||
if (this.workspaceUserId) {
|
||||
badge.textContent = `Workspace: user ${this.workspaceUserId}`;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
setupDevAutoReload() {
|
||||
const isLocalhost =
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1';
|
||||
if (!isLocalhost) return;
|
||||
|
||||
let lastToken = null;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const resp = await fetch(`/static/.reload-token?ts=${Date.now()}`, { cache: 'no-store' });
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const token = (await resp.text()).trim();
|
||||
if (lastToken === null) {
|
||||
lastToken = token;
|
||||
return;
|
||||
}
|
||||
if (token && token !== lastToken) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Dev hook file may not exist yet.
|
||||
}
|
||||
};
|
||||
|
||||
setInterval(poll, 1000);
|
||||
poll();
|
||||
}
|
||||
|
||||
apiFetch(url, init = {}) {
|
||||
const next = { ...init };
|
||||
const headers = new Headers(init.headers || {});
|
||||
@@ -60,7 +123,19 @@ class TextEditor {
|
||||
}
|
||||
next.headers = headers;
|
||||
next.credentials = 'include';
|
||||
return fetch(url, next);
|
||||
let finalUrl = url;
|
||||
if (this.workspaceUserId && typeof url === 'string' && url.startsWith('/api/')) {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (!parsed.searchParams.has('workspace_user_id')) {
|
||||
parsed.searchParams.set('workspace_user_id', this.workspaceUserId);
|
||||
}
|
||||
finalUrl = parsed.pathname + parsed.search;
|
||||
} catch (_error) {
|
||||
// ignore URL parse failure and use original
|
||||
}
|
||||
}
|
||||
return fetch(finalUrl, next);
|
||||
}
|
||||
|
||||
disposePyWorker() {
|
||||
@@ -77,7 +152,7 @@ class TextEditor {
|
||||
|
||||
ensurePyWorker() {
|
||||
if (!this.pyWorker) {
|
||||
const worker = new Worker('/static/pyodide-worker.js');
|
||||
const worker = new Worker('/static/pyodide-worker.js?v=3');
|
||||
this.pyWorker = worker;
|
||||
worker.onmessage = (event) => this.handlePyWorkerMessage(event);
|
||||
}
|
||||
@@ -126,6 +201,19 @@ class TextEditor {
|
||||
this.pyodideInited = true;
|
||||
}
|
||||
|
||||
prewarmPyWorker() {
|
||||
if (this.pyodideInited || this.workerWarmupPromise) {
|
||||
return;
|
||||
}
|
||||
this.workerWarmupPromise = this.ensurePyodideReady()
|
||||
.catch(() => {
|
||||
// Ignore warm-up failures; next foreground run will retry.
|
||||
})
|
||||
.finally(() => {
|
||||
this.workerWarmupPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
loadSessionState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.sessionStorageKey);
|
||||
@@ -139,11 +227,13 @@ class TextEditor {
|
||||
|
||||
saveSessionState() {
|
||||
try {
|
||||
const runFileSelect = document.getElementById('run-file-select');
|
||||
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
||||
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
|
||||
const session = {
|
||||
openTabPaths: this.openTabs.map((tab) => tab.path),
|
||||
activeTabPath: this.activeTabPath,
|
||||
selectedRunFile: runFileSelect ? runFileSelect.value : '',
|
||||
runMainChecked: Boolean(runMainCheckbox && runMainCheckbox.checked),
|
||||
panel16x16Checked: Boolean(panelModeCheckbox && panelModeCheckbox.checked),
|
||||
expandedDirs: Array.from(this.expandedDirs || []),
|
||||
selectedPath: this.selectedPath || '',
|
||||
selectedIsDirectory: Boolean(this.selectedIsDirectory)
|
||||
@@ -170,9 +260,13 @@ class TextEditor {
|
||||
if (session.activeTabPath && this.findTab(session.activeTabPath)) {
|
||||
this.switchToTab(session.activeTabPath);
|
||||
}
|
||||
const runFileSelect = document.getElementById('run-file-select');
|
||||
if (runFileSelect && typeof session.selectedRunFile === 'string') {
|
||||
runFileSelect.value = session.selectedRunFile;
|
||||
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
||||
if (runMainCheckbox && typeof session.runMainChecked === 'boolean') {
|
||||
runMainCheckbox.checked = session.runMainChecked;
|
||||
}
|
||||
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
|
||||
if (panelModeCheckbox && typeof session.panel16x16Checked === 'boolean') {
|
||||
panelModeCheckbox.checked = session.panel16x16Checked;
|
||||
}
|
||||
this.saveSessionState();
|
||||
}
|
||||
@@ -256,7 +350,7 @@ class TextEditor {
|
||||
setupEditor() {
|
||||
this.editor = new EditorView({
|
||||
doc: '',
|
||||
extensions: [basicSetup],
|
||||
extensions: [basicSetup, this.languageCompartment.of([])],
|
||||
parent: document.getElementById('editor')
|
||||
});
|
||||
|
||||
@@ -323,10 +417,88 @@ class TextEditor {
|
||||
this.hideCompletionDropdown();
|
||||
this.updateActiveTabContent();
|
||||
this.markAsModified();
|
||||
this.scheduleAutoCompletion();
|
||||
this.scheduleDiagnostics();
|
||||
}
|
||||
})(this.editor.dispatch);
|
||||
}
|
||||
|
||||
setLspStatus(text, title = '') {
|
||||
const status = document.getElementById('lsp-status');
|
||||
if (!status) return;
|
||||
status.textContent = text;
|
||||
status.title = title || text;
|
||||
}
|
||||
|
||||
scheduleDiagnostics() {
|
||||
if (this.diagnosticsTimer) {
|
||||
clearTimeout(this.diagnosticsTimer);
|
||||
}
|
||||
this.diagnosticsTimer = setTimeout(() => {
|
||||
this.runDiagnostics();
|
||||
}, 220);
|
||||
}
|
||||
|
||||
async runDiagnostics() {
|
||||
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
|
||||
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
|
||||
return;
|
||||
}
|
||||
const requestId = ++this.diagnosticsRequestId;
|
||||
this.setLspStatus('LSP: checking...', 'Running Jedi syntax diagnostics');
|
||||
try {
|
||||
await this.ensurePyodideReady();
|
||||
const extraFiles = {};
|
||||
for (const tab of this.openTabs) {
|
||||
if (tab.path && tab.path.toLowerCase().endsWith('.py')) {
|
||||
extraFiles[tab.path] = tab.content;
|
||||
}
|
||||
}
|
||||
const data = await this.callPyWorker('diagnostics', {
|
||||
path: this.currentFilePath,
|
||||
content: this.editor.state.doc.toString(),
|
||||
extra_files: extraFiles
|
||||
});
|
||||
if (requestId !== this.diagnosticsRequestId) return;
|
||||
const diagnostics = Array.isArray(data.diagnostics) ? data.diagnostics : [];
|
||||
if (diagnostics.length === 0) {
|
||||
this.setLspStatus('LSP: OK', 'No syntax errors');
|
||||
} else {
|
||||
const first = diagnostics[0];
|
||||
const msg = `${first.message || 'Syntax error'} (line ${first.line ?? '?'})`;
|
||||
this.setLspStatus(`LSP: ${diagnostics.length} issue(s)`, msg);
|
||||
}
|
||||
} catch (_error) {
|
||||
this.setLspStatus('LSP: unavailable', 'Diagnostics failed');
|
||||
}
|
||||
}
|
||||
|
||||
scheduleAutoCompletion() {
|
||||
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
|
||||
return;
|
||||
}
|
||||
const cursor = this.editor.state.selection.main.head;
|
||||
const doc = this.editor.state.doc.toString();
|
||||
const prev = cursor > 0 ? doc[cursor - 1] : '';
|
||||
if (!(prev === '.' || /[A-Za-z0-9_]/.test(prev))) {
|
||||
return;
|
||||
}
|
||||
if (this.autoCompleteTimer) {
|
||||
clearTimeout(this.autoCompleteTimer);
|
||||
}
|
||||
this.autoCompleteTimer = setTimeout(() => {
|
||||
this.showCompletionDropdown();
|
||||
}, 140);
|
||||
}
|
||||
|
||||
setLanguageForPath(path) {
|
||||
const ext = (path || '').toLowerCase();
|
||||
const language = ext.endsWith('.py') ? python() : [];
|
||||
this.editor.dispatch({
|
||||
effects: this.languageCompartment.reconfigure(language),
|
||||
});
|
||||
}
|
||||
|
||||
getCursorLineAndColumn() {
|
||||
const cursor = this.editor.state.selection.main.head;
|
||||
const lineInfo = this.editor.state.doc.lineAt(cursor);
|
||||
@@ -566,6 +738,27 @@ class TextEditor {
|
||||
this.stopPython();
|
||||
});
|
||||
|
||||
const ledRunBtn = document.getElementById('led-run-btn');
|
||||
if (ledRunBtn) {
|
||||
ledRunBtn.addEventListener('click', () => {
|
||||
this.runPython();
|
||||
});
|
||||
}
|
||||
const ledStopBtn = document.getElementById('led-stop-btn');
|
||||
if (ledStopBtn) {
|
||||
ledStopBtn.addEventListener('click', () => {
|
||||
this.stopPython();
|
||||
});
|
||||
}
|
||||
const ledCloseBtn = document.getElementById('led-close-btn');
|
||||
if (ledCloseBtn) {
|
||||
ledCloseBtn.addEventListener('click', () => {
|
||||
this.ledPanelDismissed = true;
|
||||
const panel = document.getElementById('led-sim-panel');
|
||||
if (panel) panel.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('create-file-btn').addEventListener('click', () => {
|
||||
this.createNewFile();
|
||||
});
|
||||
@@ -590,15 +783,26 @@ class TextEditor {
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
document.getElementById('run-file-select').addEventListener('change', (event) => {
|
||||
const selectedPath = event.target.value;
|
||||
if (!selectedPath) return;
|
||||
const tab = this.findTab(selectedPath);
|
||||
if (tab) {
|
||||
this.switchToTab(selectedPath);
|
||||
}
|
||||
this.saveSessionState();
|
||||
});
|
||||
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
||||
if (runMainCheckbox) {
|
||||
runMainCheckbox.addEventListener('change', () => {
|
||||
this.saveSessionState();
|
||||
this.updateRunButtonState();
|
||||
});
|
||||
}
|
||||
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
|
||||
if (panelModeCheckbox) {
|
||||
panelModeCheckbox.addEventListener('change', () => {
|
||||
this.saveSessionState();
|
||||
if (!panelModeCheckbox.checked && this.ledPanelWindow && !this.ledPanelWindow.closed) {
|
||||
this.ledPanelWindow.close();
|
||||
this.ledPanelWindow = null;
|
||||
}
|
||||
if (this.lastLedFrame) {
|
||||
this.renderLedSimulation(this.lastLedFrame);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -644,29 +848,9 @@ class TextEditor {
|
||||
this.closeTab(closeButton.dataset.path);
|
||||
});
|
||||
});
|
||||
this.renderRunFileSelect();
|
||||
this.saveSessionState();
|
||||
}
|
||||
|
||||
renderRunFileSelect() {
|
||||
const select = document.getElementById('run-file-select');
|
||||
if (!select) return;
|
||||
const pythonTabs = this.openTabs
|
||||
.map((tab) => tab.path)
|
||||
.filter((path) => typeof path === 'string' && path.toLowerCase().endsWith('.py'));
|
||||
const currentValue = select.value;
|
||||
select.innerHTML = '<option value="">Run active file</option>';
|
||||
pythonTabs.forEach((path) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = path;
|
||||
option.textContent = path.startsWith('code/') ? path.slice('code/'.length) : path;
|
||||
if (path === currentValue || path === this.currentFilePath) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
findTab(path) {
|
||||
return this.openTabs.find((tab) => tab.path === path);
|
||||
}
|
||||
@@ -705,10 +889,12 @@ class TextEditor {
|
||||
}
|
||||
});
|
||||
this.ignoreNextChange = false;
|
||||
this.setLanguageForPath(path);
|
||||
const currentFileEl = document.getElementById('current-file');
|
||||
if (currentFileEl) currentFileEl.textContent = path;
|
||||
this.setEditorReadOnly(this.isReadOnlyPath(path));
|
||||
this.updateRunButtonState();
|
||||
this.scheduleDiagnostics();
|
||||
if (tab.isModified) {
|
||||
this.markAsModified();
|
||||
} else {
|
||||
@@ -737,6 +923,7 @@ class TextEditor {
|
||||
this.activeTabPath = null;
|
||||
this.currentFilePath = null;
|
||||
this.setEditorReadOnly(false);
|
||||
this.setLanguageForPath('');
|
||||
this.ignoreNextChange = true;
|
||||
this.editor.dispatch({
|
||||
changes: {
|
||||
@@ -749,6 +936,7 @@ class TextEditor {
|
||||
const currentFileEl = document.getElementById('current-file');
|
||||
if (currentFileEl) currentFileEl.textContent = 'No file selected';
|
||||
this.updateRunButtonState();
|
||||
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
|
||||
this.markAsSaved();
|
||||
}
|
||||
}
|
||||
@@ -1021,12 +1209,14 @@ class TextEditor {
|
||||
}
|
||||
});
|
||||
this.ignoreNextChange = false;
|
||||
this.setLanguageForPath(filePath);
|
||||
this.markAsSaved();
|
||||
this.setEditorReadOnly(this.isReadOnlyPath(filePath));
|
||||
|
||||
const currentFileEl = document.getElementById('current-file');
|
||||
if (currentFileEl) currentFileEl.textContent = filePath;
|
||||
this.updateRunButtonState();
|
||||
this.scheduleDiagnostics();
|
||||
this.renderTabs();
|
||||
this.saveSessionState();
|
||||
} catch (error) {
|
||||
@@ -1107,6 +1297,7 @@ class TextEditor {
|
||||
const currentFileEl = document.getElementById('current-file');
|
||||
if (currentFileEl) currentFileEl.textContent = 'No file selected';
|
||||
this.updateRunButtonState();
|
||||
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
|
||||
}
|
||||
|
||||
this.directoryCache.clear();
|
||||
@@ -1129,6 +1320,7 @@ class TextEditor {
|
||||
} else {
|
||||
this.activeTabPath = null;
|
||||
this.currentFilePath = null;
|
||||
this.setLanguageForPath('');
|
||||
this.ignoreNextChange = true;
|
||||
this.editor.dispatch({
|
||||
changes: {
|
||||
@@ -1150,9 +1342,13 @@ class TextEditor {
|
||||
updateRunButtonState() {
|
||||
const runButton = document.getElementById('run-btn');
|
||||
const stopButton = document.getElementById('stop-btn');
|
||||
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
||||
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
|
||||
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
|
||||
runButton.disabled = !hasPythonFile || this.isPythonRunning;
|
||||
const canRun = runMainSelected || hasPythonFile;
|
||||
runButton.disabled = !canRun;
|
||||
stopButton.disabled = !this.isPythonRunning;
|
||||
this.updateLedWindowControls();
|
||||
}
|
||||
|
||||
clearConsole() {
|
||||
@@ -1165,9 +1361,147 @@ class TextEditor {
|
||||
}
|
||||
}
|
||||
|
||||
maybePrepareLedWindow(files) {
|
||||
const importsNeoPixel = Object.values(files || {}).some((content) =>
|
||||
typeof content === 'string' &&
|
||||
/\bimport\s+neopixel\b|\bfrom\s+neopixel\s+import\b/.test(content)
|
||||
);
|
||||
if (!importsNeoPixel) return;
|
||||
this.ledPanelDismissed = false;
|
||||
this.ensureLedWindow();
|
||||
}
|
||||
|
||||
ensureLedWindow() {
|
||||
const panel = document.getElementById('led-sim-panel');
|
||||
if (!panel) return null;
|
||||
if (!this.ledPanelDismissed) {
|
||||
panel.classList.remove('hidden');
|
||||
}
|
||||
this.updateLedWindowControls();
|
||||
return panel;
|
||||
}
|
||||
|
||||
ensureLedPanelWindow() {
|
||||
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
|
||||
return this.ledPanelWindow;
|
||||
}
|
||||
const win = window.open('', 'neopixel-16x16', 'width=760,height=760');
|
||||
if (!win) return null;
|
||||
win.document.title = 'NeoPixel 16x16 Panel';
|
||||
win.document.body.innerHTML = `
|
||||
<style>
|
||||
body { margin: 0; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; padding: 12px; }
|
||||
.meta { margin-bottom: 10px; color: #9ca3af; font-size: 13px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(16, 24px); grid-auto-rows: 24px; gap: 6px; }
|
||||
.led { width: 24px; height: 24px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); box-shadow: inset 0 0 8px rgba(0,0,0,0.45); }
|
||||
</style>
|
||||
<div id="meta" class="meta">Waiting for frame...</div>
|
||||
<div id="grid" class="grid"></div>
|
||||
`;
|
||||
this.ledPanelWindow = win;
|
||||
return win;
|
||||
}
|
||||
|
||||
closeLedPanelWindow() {
|
||||
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
|
||||
this.ledPanelWindow.close();
|
||||
}
|
||||
this.ledPanelWindow = null;
|
||||
}
|
||||
|
||||
updateLedWindowControls() {
|
||||
const runBtn = document.getElementById('led-run-btn');
|
||||
const stopBtn = document.getElementById('led-stop-btn');
|
||||
if (!runBtn || !stopBtn) return;
|
||||
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
||||
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
|
||||
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
|
||||
runBtn.disabled = !(runMainSelected || hasPythonFile);
|
||||
stopBtn.disabled = !this.isPythonRunning;
|
||||
}
|
||||
|
||||
renderLedSimulation(frame) {
|
||||
this.lastLedFrame = frame;
|
||||
this.ledPanelDismissed = false;
|
||||
if (!frame) return;
|
||||
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
|
||||
const panelMode = Boolean(panelModeCheckbox && panelModeCheckbox.checked);
|
||||
const pixels = Array.isArray(frame.pixels) ? frame.pixels : [];
|
||||
if (panelMode) {
|
||||
const panelWindow = this.ensureLedPanelWindow();
|
||||
if (!panelWindow || panelWindow.closed) return;
|
||||
const meta = panelWindow.document.getElementById('meta');
|
||||
const grid = panelWindow.document.getElementById('grid');
|
||||
if (!meta || !grid) return;
|
||||
meta.textContent = `pin=${frame.pin ?? '?'} | leds=${pixels.length} | bpp=${frame.bpp ?? 3} | mode=16x16`;
|
||||
grid.innerHTML = '';
|
||||
const panelSize = 16 * 16;
|
||||
for (let panelIndex = 0; panelIndex < panelSize; panelIndex += 1) {
|
||||
const row = Math.floor(panelIndex / 16);
|
||||
const col = panelIndex % 16;
|
||||
const ledIndex = row % 2 === 0 ? row * 16 + (15 - col) : row * 16 + col;
|
||||
const px = pixels[ledIndex] || [0, 0, 0];
|
||||
const r = Number(px?.[0] ?? 0);
|
||||
const g = Number(px?.[1] ?? 0);
|
||||
const b = Number(px?.[2] ?? 0);
|
||||
const div = panelWindow.document.createElement('div');
|
||||
div.className = 'led';
|
||||
div.title = `panel(${row},${col}) -> #${ledIndex} (${r}, ${g}, ${b})`;
|
||||
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
||||
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
|
||||
grid.appendChild(div);
|
||||
}
|
||||
} else {
|
||||
const panel = this.ensureLedWindow();
|
||||
if (!panel) return;
|
||||
const meta = document.getElementById('led-meta');
|
||||
const grid = document.getElementById('led-grid');
|
||||
if (!meta || !grid) return;
|
||||
meta.textContent = `pin=${frame.pin ?? '?'} | leds=${pixels.length} | bpp=${frame.bpp ?? 3}`;
|
||||
grid.innerHTML = '';
|
||||
grid.classList.remove('panel-mode');
|
||||
pixels.forEach((px, i) => {
|
||||
const r = Number(px?.[0] ?? 0);
|
||||
const g = Number(px?.[1] ?? 0);
|
||||
const b = Number(px?.[2] ?? 0);
|
||||
const div = document.createElement('div');
|
||||
div.className = 'led';
|
||||
div.title = `#${i} (${r}, ${g}, ${b})`;
|
||||
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
||||
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
processSimulationLines(lines) {
|
||||
for (const line of lines || []) {
|
||||
if (typeof line !== 'string') continue;
|
||||
const marker = '[neopixel-json]';
|
||||
const idx = line.indexOf(marker);
|
||||
if (idx === -1) continue;
|
||||
const jsonPart = line.slice(idx + marker.length).trim();
|
||||
if (!jsonPart) continue;
|
||||
try {
|
||||
const payload = JSON.parse(jsonPart);
|
||||
if (payload && payload.type === 'neopixel') {
|
||||
this.renderLedSimulation(payload);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore malformed simulation payloads.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendConsoleOutput(lines) {
|
||||
if (!Array.isArray(lines) || lines.length === 0) return;
|
||||
this.consolePendingText += lines.join('');
|
||||
this.processSimulationLines(lines);
|
||||
const visibleLines = lines.filter((line) => {
|
||||
if (typeof line !== 'string') return false;
|
||||
return !line.includes('[neopixel-json]');
|
||||
});
|
||||
if (visibleLines.length === 0) return;
|
||||
this.consolePendingText += visibleLines.join('');
|
||||
if (this.consoleFlushTimer) return;
|
||||
this.consoleFlushTimer = setTimeout(() => {
|
||||
const consoleOutput = document.getElementById('console-output');
|
||||
@@ -1185,8 +1519,8 @@ class TextEditor {
|
||||
}
|
||||
|
||||
async runPython() {
|
||||
const runFileSelect = document.getElementById('run-file-select');
|
||||
const selectedRunFile = runFileSelect && runFileSelect.value ? runFileSelect.value : this.currentFilePath;
|
||||
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
||||
const selectedRunFile = runMainCheckbox && runMainCheckbox.checked ? 'code/main.py' : this.currentFilePath;
|
||||
if (selectedRunFile && selectedRunFile !== this.currentFilePath && this.findTab(selectedRunFile)) {
|
||||
this.switchToTab(selectedRunFile);
|
||||
}
|
||||
@@ -1214,6 +1548,7 @@ class TextEditor {
|
||||
files[tab.path] = tab.content;
|
||||
}
|
||||
}
|
||||
this.maybePrepareLedWindow(files);
|
||||
this.clearConsole();
|
||||
const args = [];
|
||||
this.appendConsoleOutput([`$ pyodide ${selectedRunFile}\n`]);
|
||||
@@ -1228,6 +1563,7 @@ class TextEditor {
|
||||
});
|
||||
if (generation === this.pyRunGeneration) {
|
||||
this.appendConsoleOutput(['\n[Finished]\n']);
|
||||
this.closeLedPanelWindow();
|
||||
}
|
||||
} catch (error) {
|
||||
this.appendConsoleOutput([`\n${error.message}\n`]);
|
||||
@@ -1244,8 +1580,10 @@ class TextEditor {
|
||||
this.pyRunGeneration += 1;
|
||||
this.disposePyWorker();
|
||||
this.isPythonRunning = false;
|
||||
this.closeLedPanelWindow();
|
||||
this.appendConsoleOutput(['\n[Execution stopped — Pyodide worker was reset]\n']);
|
||||
this.updateRunButtonState();
|
||||
this.prewarmPyWorker();
|
||||
}
|
||||
|
||||
async deleteSelected() {
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
@@ -23,6 +24,7 @@ body {
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@@ -117,6 +119,9 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
@@ -191,15 +196,6 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#run-file-select {
|
||||
min-width: 220px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.editor-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -225,10 +221,28 @@ body {
|
||||
background-color: #edf2f7;
|
||||
}
|
||||
|
||||
.run-main-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
color: #374151;
|
||||
background: white;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-main-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
@@ -350,6 +364,89 @@ body {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.led-sim-panel {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.led-sim-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.led-sim-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.led-sim-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.led-sim-actions button {
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: 1px solid #374151;
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.led-sim-actions button:hover:not(:disabled) {
|
||||
background: #243244;
|
||||
}
|
||||
|
||||
.led-sim-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.led-meta {
|
||||
margin-top: 0.5rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.led-grid {
|
||||
margin-top: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-height: 132px;
|
||||
padding: 0.1rem 0 0.2rem;
|
||||
}
|
||||
|
||||
.led-grid.panel-mode {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(16, minmax(0, 24px));
|
||||
grid-auto-rows: 24px;
|
||||
justify-content: start;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
max-height: 470px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.led {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.console-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
@@ -445,14 +542,106 @@ body {
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
width: 100%;
|
||||
max-height: 30vh;
|
||||
min-height: 170px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0.7rem 0.8rem;
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
padding: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
width: 100%;
|
||||
order: 4;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-actions button {
|
||||
flex: 1 1 100px;
|
||||
}
|
||||
|
||||
.run-main-toggle {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
padding: 0.3rem 0.35rem;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
min-height: 46vh;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 90%;
|
||||
margin: 20% auto;
|
||||
}
|
||||
|
||||
.led-grid {
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.led-sim-header {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.led-sim-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.console-container {
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
|
||||
@@ -11,6 +11,8 @@ def _reload_app(tmp_path, monkeypatch, **env):
|
||||
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.setenv("AUTH_REGISTER_OPEN", "true")
|
||||
monkeypatch.setenv("AUTH_INVITE_ONLY", "false")
|
||||
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
|
||||
@@ -26,7 +28,7 @@ def test_auth_status_public(tmp_path, monkeypatch):
|
||||
with TestClient(_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="false")) as client:
|
||||
r = client.get("/api/auth/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"auth_enabled": False, "register_open": True}
|
||||
assert r.json() == {"auth_enabled": False, "register_open": True, "invite_required": False}
|
||||
|
||||
|
||||
def test_register_login_and_api_access(tmp_path, monkeypatch):
|
||||
@@ -85,6 +87,53 @@ def test_register_closed(tmp_path, monkeypatch):
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_admin_can_create_invite_and_register_with_token(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99", "invite_token": None})
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
invite = client.post("/api/users/invites", json={"email": "newuser@example.com", "expires_days": 7})
|
||||
assert invite.status_code == 200
|
||||
invite_url = invite.json()["invite_url"]
|
||||
token = invite_url.split("invite=", 1)[1]
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
reg = client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "newuser", "password": "password99", "invite_token": token},
|
||||
)
|
||||
assert reg.status_code == 200
|
||||
assert reg.json()["username"] == "newuser"
|
||||
|
||||
|
||||
def test_admin_can_create_link_only_invite(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99", "invite_token": None})
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7})
|
||||
assert invite.status_code == 200
|
||||
body = invite.json()
|
||||
assert body["invite_url"].startswith("http://")
|
||||
assert body["delivered"] is False
|
||||
|
||||
|
||||
def test_invite_required_blocks_public_register(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
AUTH_ENABLED="true",
|
||||
AUTH_REGISTER_OPEN="true",
|
||||
AUTH_INVITE_ONLY="true",
|
||||
)
|
||||
) as client:
|
||||
blocked = client.post("/api/auth/register", json={"username": "plain", "password": "password99"})
|
||||
assert blocked.status_code == 403
|
||||
|
||||
|
||||
def test_superuser_lists_and_creates_users(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
@@ -121,6 +170,93 @@ def test_non_superuser_cannot_list_users(tmp_path, monkeypatch):
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_users_have_isolated_workspaces(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
|
||||
save = client.post("/api/file/code/only_alice.py", json={"content": "owner = 'alice'\n"})
|
||||
assert save.status_code == 200
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
|
||||
missing = client.get("/api/file/code/only_alice.py")
|
||||
assert missing.status_code == 404
|
||||
listing = client.get("/api/files", params={"path": "code"})
|
||||
assert listing.status_code == 200
|
||||
names = {item["name"] for item in listing.json()["files"]}
|
||||
assert "only_alice.py" not in names
|
||||
|
||||
|
||||
def test_lib_is_shared_read_only_across_users(tmp_path, monkeypatch):
|
||||
shared_lib = tmp_path / "lib"
|
||||
shared_lib.mkdir(parents=True, exist_ok=True)
|
||||
(shared_lib / "shared.py").write_text("VALUE = 42\n", encoding="utf-8")
|
||||
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
|
||||
lib_read = client.get("/api/file/lib/shared.py")
|
||||
assert lib_read.status_code == 200
|
||||
assert "VALUE = 42" in lib_read.json()["content"]
|
||||
lib_write = client.post("/api/file/lib/shared.py", json={"content": "VALUE = 0\n"})
|
||||
assert lib_write.status_code == 403
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
|
||||
lib_read_bob = client.get("/api/file/lib/shared.py")
|
||||
assert lib_read_bob.status_code == 200
|
||||
assert "VALUE = 42" in lib_read_bob.json()["content"]
|
||||
|
||||
|
||||
def test_superuser_can_open_other_user_workspace(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
bob_id = me["user"]["id"]
|
||||
client.post("/api/file/code/bob_only.py", json={"content": "owner='bob'\n"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
as_bob = client.get("/api/file/code/bob_only.py", params={"workspace_user_id": bob_id})
|
||||
assert as_bob.status_code == 200
|
||||
assert "owner='bob'" in as_bob.json()["content"]
|
||||
|
||||
|
||||
def test_non_admin_cannot_override_workspace_user(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
|
||||
denied = client.get("/api/files", params={"workspace_user_id": 1})
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_login_serves_page(client):
|
||||
r = client.get("/login")
|
||||
assert r.status_code == 200
|
||||
|
||||
44
tests/test_led_patterns.py
Normal file
44
tests/test_led_patterns.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_patterns_module():
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
module_path = repo_root / "workspace" / "code" / "led_patterns.py"
|
||||
spec = importlib.util.spec_from_file_location("led_patterns", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec is not None and spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_rainbow_frame_shape_and_bounds():
|
||||
patterns = _load_patterns_module()
|
||||
frame = patterns.rainbow_frame(12, 3)
|
||||
assert len(frame) == 12
|
||||
for color in frame:
|
||||
assert len(color) == 3
|
||||
assert all(0 <= c <= 255 for c in color)
|
||||
|
||||
|
||||
def test_chase_frame_has_head_and_tail():
|
||||
patterns = _load_patterns_module()
|
||||
frame = patterns.chase_frame(8, 5, color=(10, 20, 30), tail=(1, 2, 3))
|
||||
assert len(frame) == 8
|
||||
assert frame[5] == (10, 20, 30)
|
||||
assert frame[4] == (1, 2, 3)
|
||||
assert sum(1 for c in frame if c != (0, 0, 0)) == 2
|
||||
|
||||
|
||||
def test_twinkle_frame_is_deterministic_for_same_inputs():
|
||||
patterns = _load_patterns_module()
|
||||
a = patterns.twinkle_frame(20, frame=9, seed=777, sparkles=4)
|
||||
b = patterns.twinkle_frame(20, frame=9, seed=777, sparkles=4)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_twinkle_frame_varies_between_frames():
|
||||
patterns = _load_patterns_module()
|
||||
a = patterns.twinkle_frame(20, frame=1, seed=777, sparkles=4)
|
||||
b = patterns.twinkle_frame(20, frame=2, seed=777, sparkles=4)
|
||||
assert a != b
|
||||
@@ -7,6 +7,9 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as ec
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
pytest.importorskip("selenium.webdriver")
|
||||
|
||||
@@ -46,3 +49,24 @@ def test_home_page_title(driver):
|
||||
)
|
||||
driver.get(f"{base}/")
|
||||
assert "Python Editor" in driver.title
|
||||
|
||||
|
||||
@pytest.mark.selenium
|
||||
def test_editor_page_loads_core_controls(driver):
|
||||
base = os.environ.get("SELENIUM_BASE_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
if not _server_reachable(base):
|
||||
pytest.skip(
|
||||
f"No server at {base}. In another terminal run: "
|
||||
"pipenv run dev (then re-run this test, or set SELENIUM_BASE_URL)."
|
||||
)
|
||||
|
||||
driver.get(f"{base}/editor")
|
||||
|
||||
# If the app is configured with AUTH_ENABLED=true, unauthenticated requests redirect to /login.
|
||||
if "/login" in (driver.current_url or ""):
|
||||
pytest.skip("Editor requires login; set AUTH_ENABLED=false for this Selenium smoke test.")
|
||||
|
||||
wait = WebDriverWait(driver, 10)
|
||||
wait.until(ec.presence_of_element_located((By.ID, "run-btn")))
|
||||
wait.until(ec.presence_of_element_located((By.ID, "stop-btn")))
|
||||
wait.until(ec.presence_of_element_located((By.ID, "file-tree")))
|
||||
|
||||
67
workspace/code/led_patterns.py
Normal file
67
workspace/code/led_patterns.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""LED pattern helpers inspired by embedded NeoPixel drivers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
|
||||
Color = tuple[int, int, int]
|
||||
|
||||
|
||||
def _clamp(channel: int) -> int:
|
||||
return max(0, min(255, int(channel)))
|
||||
|
||||
|
||||
def wheel(pos: int) -> Color:
|
||||
"""Return a rainbow color for position 0-255."""
|
||||
pos = 255 - (pos & 255)
|
||||
if pos < 85:
|
||||
return (_clamp(255 - pos * 3), 0, _clamp(pos * 3))
|
||||
if pos < 170:
|
||||
pos -= 85
|
||||
return (0, _clamp(pos * 3), _clamp(255 - pos * 3))
|
||||
pos -= 170
|
||||
return (_clamp(pos * 3), _clamp(255 - pos * 3), 0)
|
||||
|
||||
|
||||
def rainbow_frame(led_count: int, frame: int, step: int = 4) -> list[Color]:
|
||||
"""Generate one rainbow frame across all LEDs."""
|
||||
if led_count <= 0:
|
||||
return []
|
||||
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
|
||||
|
||||
|
||||
def chase_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
color: Color = (255, 120, 0),
|
||||
tail: Color = (16, 0, 0),
|
||||
) -> list[Color]:
|
||||
"""Generate a two-pixel chase pattern."""
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
|
||||
head = frame % led_count
|
||||
trail = (head - 1) % led_count
|
||||
out[trail] = tuple(_clamp(v) for v in tail) # type: ignore[assignment]
|
||||
out[head] = tuple(_clamp(v) for v in color) # type: ignore[assignment]
|
||||
return out
|
||||
|
||||
|
||||
def twinkle_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
base: Color = (0, 0, 8),
|
||||
sparkle: Color = (255, 255, 180),
|
||||
sparkles: int = 3,
|
||||
seed: int = 1337,
|
||||
) -> list[Color]:
|
||||
"""Generate deterministic twinkle frames for testing/replay."""
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out: list[Color] = [tuple(_clamp(v) for v in base) for _ in range(led_count)] # type: ignore[list-item]
|
||||
rng = random.Random(seed + frame)
|
||||
for _ in range(min(max(0, sparkles), led_count)):
|
||||
idx = rng.randrange(led_count)
|
||||
out[idx] = tuple(_clamp(v) for v in sparkle) # type: ignore[assignment]
|
||||
return out
|
||||
50
workspace/code/led_tutorial.py
Normal file
50
workspace/code/led_tutorial.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""LED tutorial script for NeoPixel in the browser editor.
|
||||
|
||||
Run this file and watch the in-app NeoPixel simulator panel.
|
||||
"""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
|
||||
LED_COUNT = 12
|
||||
np = neopixel.NeoPixel(Pin(4), LED_COUNT)
|
||||
|
||||
|
||||
def show_step(title: str):
|
||||
print(f"\n--- {title} ---")
|
||||
|
||||
|
||||
show_step("Step 1: single colors")
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
time.sleep(0.2)
|
||||
np[0] = (255, 0, 0) # red
|
||||
np[1] = (0, 255, 0) # green
|
||||
np[2] = (0, 0, 255) # blue
|
||||
np.write()
|
||||
time.sleep(0.8)
|
||||
|
||||
show_step("Step 2: fill strip")
|
||||
np.fill((40, 0, 120))
|
||||
np.write()
|
||||
time.sleep(0.6)
|
||||
|
||||
show_step("Step 3: moving pixel")
|
||||
for i in range(len(np)):
|
||||
np.fill((0, 0, 0))
|
||||
np[i] = (255, 120, 0)
|
||||
np.write()
|
||||
time.sleep(0.06)
|
||||
|
||||
show_step("Step 4: simple pulse")
|
||||
for level in list(range(0, 200, 20)) + list(range(200, -1, -20)):
|
||||
np.fill((level, 0, level // 3))
|
||||
np.write()
|
||||
time.sleep(0.05)
|
||||
|
||||
show_step("Done")
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
print("Tutorial complete.")
|
||||
12
workspace/code/neopixel_demo.py
Normal file
12
workspace/code/neopixel_demo.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Example ESP32 NeoPixel script using mock modules in browser."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 8)
|
||||
np.fill((0, 0, 0))
|
||||
np[0] = (255, 0, 0)
|
||||
np[1] = (0, 255, 0)
|
||||
np[2] = (0, 0, 255)
|
||||
np.write()
|
||||
32
workspace/code/neopixel_time_test.py
Normal file
32
workspace/code/neopixel_time_test.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""NeoPixel time-based animation test for the browser simulator."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 50)
|
||||
|
||||
|
||||
def wheel(pos: int) -> tuple[int, int, int]:
|
||||
"""Generate rainbow colors across 0-255 positions."""
|
||||
pos = 255 - (pos & 255)
|
||||
if pos < 85:
|
||||
return (255 - pos * 3, 0, pos * 3)
|
||||
if pos < 170:
|
||||
pos -= 85
|
||||
return (0, pos * 3, 255 - pos * 3)
|
||||
pos -= 170
|
||||
return (pos * 3, 255 - pos * 3, 0)
|
||||
|
||||
|
||||
print("Starting NeoPixel time test...")
|
||||
for frame in range(60):
|
||||
for i in range(len(np)):
|
||||
np[i] = wheel((i * 256 // len(np) + frame * 4) & 255)
|
||||
np.write()
|
||||
time.sleep(0.08)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
print("NeoPixel time test complete.")
|
||||
41
workspace/code/panel16_bounce.py
Normal file
41
workspace/code/panel16_bounce.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""16x16 bouncing pixel with fading trail."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
|
||||
|
||||
trail = [[0 for _ in range(PANEL_W)] for _ in range(PANEL_H)]
|
||||
x = 0
|
||||
y = 0
|
||||
vx = 1
|
||||
vy = 1
|
||||
|
||||
for _frame in range(420):
|
||||
for yy in range(PANEL_H):
|
||||
for xx in range(PANEL_W):
|
||||
trail[yy][xx] = max(0, trail[yy][xx] - 14)
|
||||
|
||||
trail[y][x] = 255
|
||||
|
||||
for yy in range(PANEL_H):
|
||||
for xx in range(PANEL_W):
|
||||
v = trail[yy][xx]
|
||||
np[xy_to_index(xx, yy)] = (clamp8(v), clamp8(v // 2), clamp8(30))
|
||||
|
||||
np.write()
|
||||
time.sleep(0.02)
|
||||
|
||||
x += vx
|
||||
y += vy
|
||||
if x <= 0 or x >= PANEL_W - 1:
|
||||
vx *= -1
|
||||
if y <= 0 or y >= PANEL_H - 1:
|
||||
vy *= -1
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
37
workspace/code/panel16_matrix_rain.py
Normal file
37
workspace/code/panel16_matrix_rain.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""16x16 matrix-style rain animation."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import random
|
||||
import time
|
||||
|
||||
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
|
||||
rng = random.Random(42)
|
||||
|
||||
heads = [rng.randrange(-PANEL_H, 0) for _ in range(PANEL_W)]
|
||||
|
||||
for _frame in range(320):
|
||||
for y in range(PANEL_H):
|
||||
for x in range(PANEL_W):
|
||||
np[xy_to_index(x, y)] = (0, 0, 0)
|
||||
|
||||
for x in range(PANEL_W):
|
||||
heads[x] += 1
|
||||
if heads[x] > PANEL_H + 6:
|
||||
heads[x] = rng.randrange(-PANEL_H, 0)
|
||||
|
||||
head_y = heads[x]
|
||||
for tail in range(8):
|
||||
y = head_y - tail
|
||||
if 0 <= y < PANEL_H:
|
||||
brightness = clamp8(255 - tail * 36)
|
||||
np[xy_to_index(x, y)] = (0, brightness, 0)
|
||||
|
||||
np.write()
|
||||
time.sleep(0.045)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
33
workspace/code/panel16_rainbow_wave.py
Normal file
33
workspace/code/panel16_rainbow_wave.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""16x16 rainbow wave animation."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
|
||||
|
||||
|
||||
def wheel(pos: int) -> tuple[int, int, int]:
|
||||
pos = 255 - (pos & 255)
|
||||
if pos < 85:
|
||||
return (clamp8(255 - pos * 3), 0, clamp8(pos * 3))
|
||||
if pos < 170:
|
||||
pos -= 85
|
||||
return (0, clamp8(pos * 3), clamp8(255 - pos * 3))
|
||||
pos -= 170
|
||||
return (clamp8(pos * 3), clamp8(255 - pos * 3), 0)
|
||||
|
||||
|
||||
for frame in range(240):
|
||||
for y in range(PANEL_H):
|
||||
for x in range(PANEL_W):
|
||||
color_pos = ((x * 10) + (y * 6) + frame * 5) & 255
|
||||
np[xy_to_index(x, y)] = wheel(color_pos)
|
||||
np.write()
|
||||
time.sleep(0.04)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
26
workspace/code/panel16_utils.py
Normal file
26
workspace/code/panel16_utils.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Helpers for 16x16 serpentine NeoPixel panel animations.
|
||||
|
||||
Mapping matches the simulator's 16x16 mode:
|
||||
- first LED at top-right
|
||||
- row 0 goes right -> left
|
||||
- row 1 goes left -> right
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
PANEL_W = 16
|
||||
PANEL_H = 16
|
||||
|
||||
|
||||
def xy_to_index(x: int, y: int, width: int = PANEL_W) -> int:
|
||||
"""Map panel coordinate (x, y) to LED index."""
|
||||
x = int(x)
|
||||
y = int(y)
|
||||
if y % 2 == 0:
|
||||
return y * width + (width - 1 - x)
|
||||
return y * width + x
|
||||
|
||||
|
||||
def clamp8(v: int) -> int:
|
||||
return max(0, min(255, int(v)))
|
||||
20
workspace/code/pattern_chase_demo.py
Normal file
20
workspace/code/pattern_chase_demo.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Chase pattern demo using led_patterns helpers."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
from led_patterns import chase_frame
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 24)
|
||||
|
||||
for frame in range(120):
|
||||
frame_colors = chase_frame(len(np), frame, color=(0, 220, 255), tail=(0, 40, 55))
|
||||
for i, color in enumerate(frame_colors):
|
||||
np[i] = color
|
||||
np.write()
|
||||
time.sleep(0.05)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
20
workspace/code/pattern_rainbow_demo.py
Normal file
20
workspace/code/pattern_rainbow_demo.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Rainbow pattern demo using led_patterns helpers."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
from led_patterns import rainbow_frame
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 256)
|
||||
|
||||
for frame in range(120):
|
||||
frame_colors = rainbow_frame(len(np), frame, step=5)
|
||||
for i, color in enumerate(frame_colors):
|
||||
np[i] = color
|
||||
np.write()
|
||||
time.sleep(0.05)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
26
workspace/code/pattern_twinkle_demo.py
Normal file
26
workspace/code/pattern_twinkle_demo.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Twinkle pattern demo using led_patterns helpers."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
from led_patterns import twinkle_frame
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 36)
|
||||
|
||||
for frame in range(120):
|
||||
frame_colors = twinkle_frame(
|
||||
len(np),
|
||||
frame,
|
||||
base=(0, 0, 6),
|
||||
sparkle=(255, 210, 130),
|
||||
sparkles=5,
|
||||
)
|
||||
for i, color in enumerate(frame_colors):
|
||||
np[i] = color
|
||||
np.write()
|
||||
time.sleep(0.08)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
68
workspace/lib/led_patterns.py
Normal file
68
workspace/lib/led_patterns.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Compatibility pattern helpers for NeoPixel demos.
|
||||
|
||||
This file mirrors `workspace/code/led_patterns.py` so imports like
|
||||
`from led_patterns import ...` work even in older worker sessions that only
|
||||
include `/workspace/lib` in `sys.path`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
|
||||
Color = tuple[int, int, int]
|
||||
|
||||
|
||||
def _clamp(channel: int) -> int:
|
||||
return max(0, min(255, int(channel)))
|
||||
|
||||
|
||||
def wheel(pos: int) -> Color:
|
||||
pos = 255 - (pos & 255)
|
||||
if pos < 85:
|
||||
return (_clamp(255 - pos * 3), 0, _clamp(pos * 3))
|
||||
if pos < 170:
|
||||
pos -= 85
|
||||
return (0, _clamp(pos * 3), _clamp(255 - pos * 3))
|
||||
pos -= 170
|
||||
return (_clamp(pos * 3), _clamp(255 - pos * 3), 0)
|
||||
|
||||
|
||||
def rainbow_frame(led_count: int, frame: int, step: int = 4) -> list[Color]:
|
||||
if led_count <= 0:
|
||||
return []
|
||||
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
|
||||
|
||||
|
||||
def chase_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
color: Color = (255, 120, 0),
|
||||
tail: Color = (16, 0, 0),
|
||||
) -> list[Color]:
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
|
||||
head = frame % led_count
|
||||
trail = (head - 1) % led_count
|
||||
out[trail] = tuple(_clamp(v) for v in tail) # type: ignore[assignment]
|
||||
out[head] = tuple(_clamp(v) for v in color) # type: ignore[assignment]
|
||||
return out
|
||||
|
||||
|
||||
def twinkle_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
base: Color = (0, 0, 8),
|
||||
sparkle: Color = (255, 255, 180),
|
||||
sparkles: int = 3,
|
||||
seed: int = 1337,
|
||||
) -> list[Color]:
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out: list[Color] = [tuple(_clamp(v) for v in base) for _ in range(led_count)] # type: ignore[list-item]
|
||||
rng = random.Random(seed + frame)
|
||||
for _ in range(min(max(0, sparkles), led_count)):
|
||||
idx = rng.randrange(led_count)
|
||||
out[idx] = tuple(_clamp(v) for v in sparkle) # type: ignore[assignment]
|
||||
return out
|
||||
19
workspace/lib/machine.py
Normal file
19
workspace/lib/machine.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Minimal MicroPython-style machine module mock for browser simulation."""
|
||||
|
||||
|
||||
class Pin:
|
||||
IN = 0
|
||||
OUT = 1
|
||||
PULL_UP = 2
|
||||
PULL_DOWN = 3
|
||||
|
||||
def __init__(self, pin_id: int, mode: int = OUT, value: int = 0):
|
||||
self.id = int(pin_id)
|
||||
self.mode = int(mode)
|
||||
self._value = 1 if value else 0
|
||||
|
||||
def value(self, new_value=None):
|
||||
if new_value is None:
|
||||
return self._value
|
||||
self._value = 1 if int(new_value) else 0
|
||||
return self._value
|
||||
56
workspace/lib/neopixel.py
Normal file
56
workspace/lib/neopixel.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""NeoPixel mock for Pyodide/browser execution.
|
||||
|
||||
Supports a useful subset of MicroPython's neopixel.NeoPixel API:
|
||||
- NeoPixel(pin, n, bpp=3, timing=1)
|
||||
- __setitem__, __getitem__, __len__
|
||||
- fill(color)
|
||||
- write() # prints current pixel buffer snapshot
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _normalize_color(value, bpp: int):
|
||||
if not hasattr(value, "__iter__"):
|
||||
raise TypeError("Color must be an iterable, e.g. (r, g, b)")
|
||||
parts = [int(v) for v in value]
|
||||
if len(parts) != bpp:
|
||||
raise ValueError(f"Expected {bpp} color channels, got {len(parts)}")
|
||||
out = []
|
||||
for channel in parts:
|
||||
out.append(max(0, min(255, channel)))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
class NeoPixel:
|
||||
def __init__(self, pin, n: int, bpp: int = 3, timing: int = 1):
|
||||
self.pin = pin
|
||||
self.n = int(n)
|
||||
self.bpp = int(bpp)
|
||||
self.timing = int(timing)
|
||||
self._buf = [tuple([0] * self.bpp) for _ in range(self.n)]
|
||||
|
||||
def __len__(self):
|
||||
return self.n
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self._buf[int(index)]
|
||||
|
||||
def __setitem__(self, index, color):
|
||||
idx = int(index)
|
||||
self._buf[idx] = _normalize_color(color, self.bpp)
|
||||
|
||||
def fill(self, color):
|
||||
c = _normalize_color(color, self.bpp)
|
||||
for i in range(self.n):
|
||||
self._buf[i] = c
|
||||
|
||||
def write(self):
|
||||
pin_id = getattr(self.pin, "id", self.pin)
|
||||
payload = {
|
||||
"type": "neopixel",
|
||||
"pin": pin_id,
|
||||
"pixels": [list(pixel) for pixel in self._buf],
|
||||
"bpp": self.bpp,
|
||||
}
|
||||
print("[neopixel-json]" + json.dumps(payload))
|
||||
Reference in New Issue
Block a user