Add initial web editor app, CLI scripts, and test scaffolding.

This introduces the FastAPI editor implementation and related project setup so the app can be run and validated locally.

Made-with: Cursor
This commit is contained in:
2026-04-11 02:14:26 +12:00
parent fb5f55cda7
commit f9bf119af6
33 changed files with 4846 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
from .main import app

Binary file not shown.

Binary file not shown.

30
src/editor_app/config.py Normal file
View File

@@ -0,0 +1,30 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "static"
def load_env_file(env_path: Path) -> None:
"""Load KEY=VALUE entries from a local .env file."""
if not env_path.exists():
return
for line in env_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
load_env_file(BASE_DIR / ".env")
WORKSPACE_ROOT = Path(
os.getenv("WORKSPACE_ROOT", "/home/jimmy/projects/connectionmachine")
)

28
src/editor_app/main.py Normal file
View File

@@ -0,0 +1,28 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
from editor_app.routers.files import router as files_router
from editor_app.routers.frontend import router as frontend_router
from editor_app.routers.python_exec import router as python_router
def create_app() -> FastAPI:
app = FastAPI()
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(frontend_router)
app.include_router(files_router)
app.include_router(python_router)
@app.on_event("startup")
async def run_configured_startup_script() -> None:
from editor_app.services import python_runner
(WORKSPACE_ROOT / "lib").mkdir(parents=True, exist_ok=True)
python_runner.run_startup_script_if_configured()
return app
app = create_app()

38
src/editor_app/models.py Normal file
View File

@@ -0,0 +1,38 @@
from pydantic import BaseModel
from typing import Optional
class FileContent(BaseModel):
content: str
class FileInfo(BaseModel):
name: str
is_directory: bool
size: Optional[int] = None
class FolderOperation(BaseModel):
path: str
class RunPythonRequest(BaseModel):
file_path: str
args: list[str] = []
class CompletionRequest(BaseModel):
file_path: str
content: str
line: int
column: int
max_results: int = 20
class MoveFileRequest(BaseModel):
source_path: str
destination_folder: str = ""
class StartupScriptRequest(BaseModel):
file_path: str

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,52 @@
from fastapi import APIRouter
from editor_app.models import FileContent, FolderOperation, MoveFileRequest
from editor_app.services import filesystem
router = APIRouter(prefix="/api")
@router.get("/files")
async def list_files(path: str = ""):
files = filesystem.list_files(path)
return {"files": files}
@router.get("/file/{file_path:path}")
async def read_file(file_path: str):
content, filename = filesystem.read_text_file(file_path)
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)
return {"message": "File saved successfully", "filename": filename}
@router.post("/file-move")
async def move_file(move_data: MoveFileRequest):
new_path, moved_type = filesystem.move_path(
source_path=move_data.source_path,
destination_folder=move_data.destination_folder,
)
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)
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)
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)
return {"message": "Folder deleted successfully"}

View File

@@ -0,0 +1,17 @@
from fastapi import APIRouter
from fastapi.responses import FileResponse
from editor_app.config import STATIC_DIR
router = APIRouter()
@router.get("/")
async def serve_home():
return FileResponse(STATIC_DIR / "home.html")
@router.get("/editor")
async def serve_frontend():
return FileResponse(STATIC_DIR / "index.html")

View File

@@ -0,0 +1,120 @@
import asyncio
from fastapi import APIRouter, HTTPException, WebSocket
from editor_app import config
from editor_app.models import CompletionRequest, RunPythonRequest, StartupScriptRequest
from editor_app.services import filesystem, python_runner
router = APIRouter(prefix="/api/python")
@router.post("/run")
async def run_python_file(run_request: RunPythonRequest):
target_path = filesystem.resolve_workspace_path(run_request.file_path)
if not target_path.exists() or not target_path.is_file():
raise HTTPException(status_code=404, detail="Python file not found")
if target_path.suffix.lower() != ".py":
raise HTTPException(status_code=400, detail="Only .py files can be run")
python_runner.run_python_file(target_path, run_request.file_path, run_request.args)
return {"message": "Python process started"}
@router.get("/output")
async def get_python_output(offset: int = 0):
return python_runner.get_output(offset)
@router.post("/stop")
async def stop_python_process():
message = python_runner.stop_python_process()
return {"message": message}
@router.post("/completions")
async def get_python_completions(completion_request: CompletionRequest):
try:
target_path = filesystem.resolve_workspace_path(completion_request.file_path)
max_results = max(1, min(completion_request.max_results, 100))
try:
import jedi
except ImportError as exc:
raise HTTPException(status_code=500, detail="jedi is not installed") from exc
script = jedi.Script(
code=completion_request.content,
path=str(target_path),
project=jedi.Project(path=str(config.WORKSPACE_ROOT)),
)
completions = script.complete(
line=completion_request.line,
column=completion_request.column,
)
return {
"completions": [
{"name": item.name, "type": item.type, "complete": item.complete}
for item in completions[:max_results]
]
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Completion failed: {exc}") from exc
@router.websocket("/ws/output")
async def websocket_python_output(websocket: WebSocket):
await websocket.accept()
offset = 0
last_running = None
last_return_code = None
while True:
data = python_runner.get_output(offset)
if data["lines"]:
await websocket.send_json(
{
"lines": data["lines"],
"next_offset": data["next_offset"],
"running": data["running"],
"return_code": data["return_code"],
}
)
offset = data["next_offset"]
last_running = data["running"]
last_return_code = data["return_code"]
# Yield between pushes to avoid overwhelming browser UI threads.
await asyncio.sleep(0.03)
else:
if data["running"] != last_running or data["return_code"] != last_return_code:
await websocket.send_json(
{
"lines": [],
"next_offset": offset,
"running": data["running"],
"return_code": data["return_code"],
}
)
last_running = data["running"]
last_return_code = data["return_code"]
# Keep the websocket active and low-latency.
await asyncio.sleep(0.2)
@router.get("/scripts")
async def list_python_scripts():
return {"scripts": python_runner.list_python_scripts()}
@router.get("/startup-script")
async def get_startup_script():
return {"file_path": python_runner.get_startup_script()}
@router.post("/startup-script")
async def set_startup_script(startup_request: StartupScriptRequest):
file_path = python_runner.set_startup_script(startup_request.file_path)
return {"message": "Startup script saved", "file_path": file_path}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,182 @@
import shutil
from pathlib import Path
from fastapi import HTTPException
from editor_app import config
from editor_app.models import FileInfo
LIB_DIR_NAME = "lib"
WRITABLE_ROOTS = {"code", "prompts"}
def normalize_relative_path(relative_path: str) -> str:
cleaned = (relative_path or "").strip().lstrip("/")
if not cleaned:
return ""
parts = [segment for segment in cleaned.split("/") if segment]
if len(parts) >= 2 and parts[0] in {"code", "prompts"}:
while len(parts) >= 2 and parts[0] == parts[1]:
parts.pop(1)
return "/".join(parts)
def resolve_workspace_path(relative_path: str) -> Path:
relative_path = normalize_relative_path(relative_path)
target_path = (config.WORKSPACE_ROOT / relative_path).resolve()
try:
target_path.relative_to(config.WORKSPACE_ROOT.resolve())
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()
lib_root = (workspace / LIB_DIR_NAME).resolve()
try:
target_path.resolve().relative_to(lib_root)
return True
except ValueError:
return False
def _ensure_not_lib_path(target_path: Path) -> None:
if _is_path_in_lib(target_path):
raise HTTPException(status_code=403, detail="lib is read-only")
def _is_writable_path(target_path: Path) -> bool:
workspace = config.WORKSPACE_ROOT.resolve()
resolved = target_path.resolve()
try:
relative = resolved.relative_to(workspace)
except ValueError:
return False
if not relative.parts:
return False
return relative.parts[0] in WRITABLE_ROOTS
def _ensure_writable_path(target_path: Path) -> None:
if not _is_writable_path(target_path):
raise HTTPException(
status_code=403,
detail="Only code and prompts are writable (lib is read-only)",
)
def list_files(path: str = "") -> list[FileInfo]:
path = normalize_relative_path(path)
target_path = config.WORKSPACE_ROOT / path if path else config.WORKSPACE_ROOT
if not target_path.exists() or not target_path.is_dir():
raise HTTPException(status_code=404, detail="Directory not found")
files = []
for item in sorted(target_path.iterdir()):
if item.name.startswith("."):
continue
files.append(
FileInfo(
name=item.name,
is_directory=item.is_dir(),
size=item.stat().st_size if item.is_file() else None,
)
)
return files
def read_text_file(file_path: str) -> tuple[str, str]:
target_path = resolve_workspace_path(file_path)
if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if target_path.is_dir():
raise HTTPException(status_code=400, detail="Path is a directory")
try:
content = target_path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="File is not a text file") from exc
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)
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)
if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if target_path.is_dir():
raise HTTPException(status_code=400, detail="Cannot delete directories")
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)
if not source.exists():
raise HTTPException(status_code=404, detail="Source path not found")
destination_dir = (
resolve_workspace_path(destination_folder)
if destination_folder
else config.WORKSPACE_ROOT
)
_ensure_not_lib_path(destination_dir)
_ensure_writable_path(destination_dir)
if not destination_dir.exists() or not destination_dir.is_dir():
raise HTTPException(status_code=404, detail="Destination folder not found")
destination = destination_dir / source.name
source_resolved = source.resolve()
destination_resolved = destination.resolve()
if destination_resolved == source_resolved:
raise HTTPException(status_code=400, detail="Path is already in that folder")
if source.is_dir():
source_prefix = str(source_resolved) + "/"
if str(destination_dir.resolve()).startswith(source_prefix):
raise HTTPException(
status_code=400, detail="Cannot move a folder into itself or its child"
)
if destination.exists():
raise HTTPException(
status_code=409,
detail="A path with that name already exists in destination",
)
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
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)
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)
if not target_path.exists():
raise HTTPException(status_code=404, detail="Folder not found")
if not target_path.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")
shutil.rmtree(target_path)

View File

@@ -0,0 +1,172 @@
import os
import subprocess
import sys
import threading
from typing import Optional
from fastapi import HTTPException
from editor_app import config
from editor_app.services import filesystem
class PythonRunnerState:
def __init__(self) -> None:
self.lock = threading.Lock()
self.process: Optional[subprocess.Popen] = None
self.output_lines: list[str] = []
self.output_base_offset = 0
self.return_code: Optional[int] = None
self.running = False
python_runner = PythonRunnerState()
STARTUP_SCRIPT_FILE = config.WORKSPACE_ROOT / ".connectionmachine_startup_script"
MAX_OUTPUT_LINES = 5000
def _append_output_line(line: str) -> None:
python_runner.output_lines.append(line)
if len(python_runner.output_lines) > MAX_OUTPUT_LINES:
overflow = len(python_runner.output_lines) - MAX_OUTPUT_LINES
if overflow > 0:
del python_runner.output_lines[:overflow]
python_runner.output_base_offset += overflow
def stream_process_output(process: subprocess.Popen) -> None:
if process.stdout is None:
return
for line in process.stdout:
with python_runner.lock:
_append_output_line(line)
def wait_for_process(process: subprocess.Popen) -> None:
return_code = process.wait()
with python_runner.lock:
python_runner.return_code = return_code
python_runner.running = False
python_runner.process = None
def run_python_file(target_path, requested_path: str, args: Optional[list[str]] = None) -> None:
run_args = [str(arg) for arg in (args or [])]
with python_runner.lock:
if python_runner.running:
raise HTTPException(status_code=409, detail="A Python process is already running")
try:
run_env = os.environ.copy()
run_env["PYTHONUNBUFFERED"] = "1"
lib_path = str((config.WORKSPACE_ROOT / "lib").resolve())
existing_pythonpath = run_env.get("PYTHONPATH", "")
if existing_pythonpath:
run_env["PYTHONPATH"] = f"{lib_path}:{existing_pythonpath}"
else:
run_env["PYTHONPATH"] = lib_path
process = subprocess.Popen(
[sys.executable, "-u", str(target_path), *run_args],
cwd=str(config.WORKSPACE_ROOT),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=run_env,
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to start process: {exc}") from exc
python_runner.process = process
command_parts = [sys.executable, requested_path, *run_args]
python_runner.output_lines = [f"$ {' '.join(command_parts)}\n"]
python_runner.output_base_offset = 0
python_runner.return_code = None
python_runner.running = True
threading.Thread(target=stream_process_output, args=(process,), daemon=True).start()
threading.Thread(target=wait_for_process, args=(process,), daemon=True).start()
def get_output(offset: int = 0) -> dict:
with python_runner.lock:
safe_offset = max(python_runner.output_base_offset, offset)
relative_index = max(0, safe_offset - python_runner.output_base_offset)
lines = python_runner.output_lines[relative_index:]
return {
"lines": lines,
"next_offset": safe_offset + len(lines),
"running": python_runner.running,
"return_code": python_runner.return_code,
}
def stop_python_process() -> str:
with python_runner.lock:
process = python_runner.process
if not python_runner.running or process is None:
return "No running process"
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
with python_runner.lock:
_append_output_line("\n[Process stopped]\n")
python_runner.running = False
python_runner.return_code = process.returncode
python_runner.process = None
return "Python process stopped"
def list_python_scripts() -> list[str]:
workspace = config.WORKSPACE_ROOT
scripts: list[str] = []
for path in workspace.rglob("*.py"):
rel = path.relative_to(workspace)
# Skip hidden paths.
if any(part.startswith(".") for part in rel.parts):
continue
scripts.append(str(rel))
scripts.sort()
return scripts
def set_startup_script(file_path: str) -> str:
target = filesystem.resolve_workspace_path(file_path)
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="Python file not found")
if target.suffix.lower() != ".py":
raise HTTPException(status_code=400, detail="Only .py files can be selected")
relative_path = str(target.relative_to(config.WORKSPACE_ROOT))
STARTUP_SCRIPT_FILE.write_text(relative_path, encoding="utf-8")
return relative_path
def get_startup_script() -> Optional[str]:
if not STARTUP_SCRIPT_FILE.exists():
return None
value = STARTUP_SCRIPT_FILE.read_text(encoding="utf-8").strip()
if not value:
return None
return value
def run_startup_script_if_configured() -> None:
startup_script = get_startup_script()
if not startup_script:
return
try:
target_path = filesystem.resolve_workspace_path(startup_script)
if not target_path.exists() or not target_path.is_file():
return
if target_path.suffix.lower() != ".py":
return
run_python_file(target_path, startup_script)
except HTTPException:
return