feat: composer improvements

This commit is contained in:
2026-04-05 09:53:01 +02:00
parent 7838760ca4
commit e1db06104e
17 changed files with 1301 additions and 332 deletions

151
CLAUDE.md
View File

@@ -205,27 +205,154 @@ form "name"
``` ```
### Dynamic Features ### Dynamic Features
```
cache 0 # never cache (re-execute)
source cpu : shell "cat /proc/loadavg" # live data at render time
source config : json "/path/config.json" # JSON file read
source ts : python "datetime.now().isoformat()" # Python expression
let name = "Relay Alpha" # variable assignment
Any page using `source`, `if`, `for`, `on_submit`, or `state` becomes **dynamic**: it compiles to an executable Python script instead of static Micron. NomadNet runs the script on each request and serves its stdout.
#### Variables
```
let name = "Relay Alpha" # string assignment
let threshold = 75 # numeric
let tags = "alpha","beta","gamma" # comma-separated → list
```
Variables are substituted with `$name` in text, labels, and other content. They work in both static and dynamic pages.
#### Data Sources
```
source var_name : type "command" [timeout=N]
```
Sources fetch data **at render time** and bind results to variables:
| Type | Description | Example |
|----------|--------------------------------------|----------------------------------------------------------|
| `shell` | Run shell command, capture stdout | `source cpu : shell "cat /proc/loadavg"` |
| `file` | Read file contents as string | `source motd : file "/etc/motd"` |
| `json` | Read + parse JSON file → dict/list | `source config : json "/etc/config.json"` |
| `python` | Evaluate Python expression | `source ts : python "datetime.now().strftime('%H:%M')"` |
| `http` | HTTP request, auto-parses JSON | `source data : http "https://api.example.com/data"` |
| `sqlite` | SQLite query → list of dicts | `source users : sqlite "/path/db" "SELECT * FROM users"` |
| `env` | Read environment variable | `source key : env "API_KEY"` |
| `param` | Read URL parameter from link | `source hash : param "hash"` |
| `rns` | Query Reticulum via `rnstatus` | `source peers : rns "peers"` |
**Shell** commands have a default 5-second timeout (override with `timeout=N`).
**Python** expressions have access to: `datetime` (the class, so `datetime.now()` works), `timedelta`, `secrets`, `os`, `json`. Expressions are evaluated via `eval()` — single expressions only, not statements.
```
# Python source examples
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
source rand_id : python "secrets.token_hex(4)"
source cpu_sim : python "secrets.randbelow(60) + 20"
source uptime : python "str(timedelta(seconds=12345))"
source hostname : python "os.uname().nodename"
```
**HTTP** requests return parsed JSON (dict/list) or raw string. Default timeout 10s.
```
# GET request — JSON auto-parsed into dict
source todo : http "https://api.example.com/todos/1"
text "Title: $todo.title"
# POST with JSON body
source result : http "https://api.example.com/search" method=POST body='{"q":"relay"}'
# Custom headers (semicolon-separated)
source data : http "https://api.example.com/data" headers='Authorization: Bearer tok123'
# Use $var references in URL, headers, and body — resolved at runtime
source token : env "API_TOKEN"
source data : http "https://api.example.com/data" headers='Authorization: Bearer $token'
```
**Env** reads server-side environment variables. Use this for secrets — tokens never appear in `.uf` source or compiled scripts.
```
source api_key : env "API_KEY"
source db_pass : env "DB_PASSWORD"
```
**SQLite** queries return a list of dicts (or a single dict for one row). Uses Python stdlib `sqlite3`.
```
# Query returns list of dicts with column names as keys
source nodes : sqlite "/data/network.db" "SELECT name, status, hops FROM nodes"
# Iterate results
for node in $nodes
label "$node.name" "$node.status ($node.hops hops)"
# Single row queries return a dict directly
source config : sqlite "/data/app.db" "SELECT value FROM config WHERE key='theme'"
text "Theme: $config.value"
```
#### Conditionals
```
if $cpu > 90 if $cpu > 90
text "ALERT: CPU critical" text "ALERT: CPU critical"
elif $cpu > 75 elif $cpu > 75
text "Warning: elevated" text "Warning: elevated"
else
text "All clear"
```
Conditions are Python expressions. `$var` references resolve to the variable's value. Supports `>`, `<`, `>=`, `<=`, `==`, `!=`, `&&` (and), `||` (or).
#### Loops
```
for peer in $peers for peer in $peers
status "$peer.name" $peer.state status "$peer.name" $peer.state
on_submit "search"
source results : shell "search.py '$query'"
text "$results"
state "counter" "/tmp/counter.json" # persistent JSON store
``` ```
Iterates over lists (from JSON sources), dicts (wrapped as single-item list), or newline-delimited strings (from shell output). Access nested fields with `$item.field`.
#### Cache Control
```
cache 0 # never cache (re-execute every request)
cache 300 # cache for 5 minutes
```
Emits the `#!c=N` header that NomadNet uses to control page caching.
#### Form Submission Handling
```
on_submit "form_name"
# Runs when the named form is submitted
# Form field values are available as $field_name
source results : shell "search.py '$query'"
text "Found: $results"
```
Field values are read from `FIELD_*` environment variables set by NomadNet.
#### Persistent State
```
state "counter" "/tmp/counter.json" # load JSON into $counter
```
Loads a JSON file into a variable. Use `_save_state(path, data)` in the generated script to persist changes.
#### Using Variables in Content
```
text "Hello, $name" # inline substitution
label "CPU" "$cpu_pct%" # in labels
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90 # as gauge values
status "$peer" $state # in status indicators
link "View $name" "/page/detail.mu" # in links
```
#### Generated Script Runtime
The compiled script includes these helpers, available in `on_submit` and source blocks:
| Helper | Description |
|-------------------------------------|-----------------------------------------------|
| `_shell(cmd, timeout=5)` | Execute shell command, return stdout |
| `_read_file(path)` | Read file contents |
| `_read_json(path)` | Read + parse JSON file |
| `_http(url, method, body, headers)` | HTTP request, auto-parse JSON response |
| `_sqlite(db_path, query)` | SQLite query → list of dicts (or single dict) |
| `_get_field(name, default)` | Read submitted form field |
| `_get_param(name, default)` | Read URL parameter |
| `_load_state(path)` | Load state from JSON file |
| `_save_state(path, data)` | Save state to JSON file |
| `_iter(val)` | Make a value iterable (list/dict/string) |
### Components ### Components
``` ```

View File

@@ -264,12 +264,19 @@ async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _read_local_page(path: str) -> dict: def _read_local_page(path: str) -> dict:
from converter import execute_dynamic_script
pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages")) pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages"))
filepath = Path(pages_dir) / path filepath = Path(pages_dir) / path
try: if not filepath.exists():
return {"content": filepath.read_text()}
except FileNotFoundError:
return {"content": None, "error": "Page not found"} return {"content": None, "error": "Page not found"}
try:
if os.access(filepath, os.X_OK):
script = filepath.read_text(encoding="utf-8")
return {"content": execute_dynamic_script(script)}
return {"content": filepath.read_text()}
except Exception as exc:
return {"content": None, "error": str(exc)}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1,6 +1,9 @@
"""µFrame compile, DSL metadata, and image upload endpoints.""" """µFrame compile, DSL metadata, and image upload endpoints."""
import os import os
import subprocess
import sys
import tempfile
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File from fastapi import APIRouter, HTTPException, UploadFile, File
@@ -14,6 +17,31 @@ from uframe.registry import get_dsl_meta
router = APIRouter() router = APIRouter()
UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images" UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images"
BACKEND_DIR = str(Path(__file__).resolve().parent)
def execute_dynamic_script(script: str, timeout: int = 10) -> str:
"""Execute a dynamic page script and return its stdout (micron output).
Used by both the compile preview and the browse page reader.
"""
env = {**os.environ, "PYTHONPATH": BACKEND_DIR}
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(script)
f.flush()
try:
result = subprocess.run(
[sys.executable, f.name],
capture_output=True, text=True, timeout=timeout,
cwd=BACKEND_DIR, env=env,
)
if result.returncode != 0 and result.stderr:
return result.stderr
return result.stdout
except subprocess.TimeoutExpired:
return "Error: script timed out"
finally:
os.unlink(f.name)
class CompileRequest(BaseModel): class CompileRequest(BaseModel):
@@ -31,12 +59,26 @@ class CompileResponse(BaseModel):
@router.post("/compile", response_model=CompileResponse) @router.post("/compile", response_model=CompileResponse)
async def compile_source(req: CompileRequest): async def compile_source(req: CompileRequest):
"""Compile µFrame .uf source into ASCII and Micron output.""" """Compile µFrame .uf source into ASCII and Micron output.
For dynamic pages, the generated script is executed and the
resolved micron output replaces the static micron in the response.
"""
try: try:
result = uframe.compile(req.source, width=req.width) result = uframe.compile(req.source, width=req.width)
micron = result.micron
if result.is_dynamic and result.script:
executed = execute_dynamic_script(result.script)
# Strip cache header line if present
lines = executed.split("\n")
if lines and lines[0].startswith("#!c="):
lines = lines[1:]
micron = "\n".join(lines)
return CompileResponse( return CompileResponse(
ascii=result.ascii, ascii=result.ascii,
micron=result.micron, micron=micron,
script=result.script, script=result.script,
is_dynamic=result.is_dynamic, is_dynamic=result.is_dynamic,
warnings=[w.message for w in result.warnings], warnings=[w.message for w in result.warnings],

View File

@@ -4,7 +4,7 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pages import router as pages_router, ensure_default_pages from pages import router as pages_router, files_router, ensure_default_pages
from docker_utils import router as docker_router from docker_utils import router as docker_router
from converter import router as converter_router from converter import router as converter_router
from browse import router as browse_router, start_browser from browse import router as browse_router, start_browser
@@ -13,6 +13,7 @@ app = FastAPI(title="µFrame Editor")
app.include_router(converter_router, prefix="/api") app.include_router(converter_router, prefix="/api")
app.include_router(pages_router, prefix="/api") app.include_router(pages_router, prefix="/api")
app.include_router(files_router, prefix="/api")
app.include_router(docker_router, prefix="/api") app.include_router(docker_router, prefix="/api")
app.include_router(browse_router, prefix="/api") app.include_router(browse_router, prefix="/api")

View File

@@ -1,13 +1,15 @@
import os import os
import shlex import shlex
import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
import uframe import uframe
router = APIRouter() router = APIRouter()
files_router = APIRouter()
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
@@ -38,7 +40,7 @@ page "Welcome" 60
def ensure_default_pages(): def ensure_default_pages():
"""Create a default index page if none exists.""" """Create default index page and .env file if they don't exist."""
PAGES_DIR.mkdir(parents=True, exist_ok=True) PAGES_DIR.mkdir(parents=True, exist_ok=True)
SOURCES_DIR.mkdir(parents=True, exist_ok=True) SOURCES_DIR.mkdir(parents=True, exist_ok=True)
@@ -54,6 +56,17 @@ def ensure_default_pages():
if not index_src.is_file(): if not index_src.is_file():
index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8") index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8")
env_path = SOURCES_DIR / ".env"
if not env_path.is_file():
env_path.write_text(
"# Environment variables for dynamic pages\n"
"# Access with: source name : env \"KEY\"\n"
"#\n"
"# Example:\n"
"# API_KEY=your-key-here\n",
encoding="utf-8",
)
class PageMeta(BaseModel): class PageMeta(BaseModel):
name: str name: str
@@ -225,3 +238,176 @@ async def delete_page(name: str):
mu_path.unlink() mu_path.unlink()
return {"deleted": name} return {"deleted": name}
# ---------------------------------------------------------------------------
# File browser endpoints
# ---------------------------------------------------------------------------
class FileEntry(BaseModel):
name: str
path: str
type: str # "file" | "folder" | "env"
size: int | None = None
last_modified: float | None = None
title: str | None = None
published: bool = False
class MkdirRequest(BaseModel):
path: str
class MoveRequest(BaseModel):
model_config = {"populate_by_name": True}
from_path: str = Field(alias="from")
to: str
class EnvRequest(BaseModel):
content: str
def _validate_relative_path(rel: str) -> Path:
"""Validate that a relative path has no traversal components and resolves
inside the expected base directories. Returns the cleaned relative Path."""
p = Path(rel)
# Reject absolute paths and any ".." components
if p.is_absolute():
raise HTTPException(status_code=400, detail="Absolute paths not allowed")
for part in p.parts:
if part == "..":
raise HTTPException(status_code=400, detail="Directory traversal not allowed")
# Extra safety: resolve against SOURCES_DIR and verify containment
resolved = (SOURCES_DIR / p).resolve()
if not str(resolved).startswith(str(SOURCES_DIR.resolve())):
raise HTTPException(status_code=400, detail="Path escapes base directory")
return p
def _file_entry(base: Path, rel_path: Path) -> FileEntry:
"""Build a FileEntry for a file or directory at base/rel_path."""
full = base / rel_path
name = rel_path.name
if full.is_dir():
return FileEntry(
name=name,
path=str(rel_path),
type="folder",
)
# .env file
if name == ".env":
stat = full.stat()
return FileEntry(
name=name,
path=str(rel_path),
type="env",
size=stat.st_size,
last_modified=stat.st_mtime,
)
# Regular file
stat = full.stat()
title = None
published = False
if full.suffix == ".uf":
try:
title = _extract_title(full.read_text(encoding="utf-8"))
except Exception:
pass
# Check published status: corresponding .mu in PAGES_DIR at same relative path
mu_rel = rel_path.with_suffix(".mu")
published = (PAGES_DIR / mu_rel).is_file()
return FileEntry(
name=name,
path=str(rel_path),
type="file",
size=stat.st_size,
last_modified=stat.st_mtime,
title=title,
published=published,
)
@files_router.get("/files", response_model=list[FileEntry])
async def list_files(path: str = Query(default="")):
"""List files and folders in SOURCES_DIR, optionally scoped to a subfolder."""
if path:
rel = _validate_relative_path(path)
else:
rel = Path(".")
target = (SOURCES_DIR / rel).resolve()
if not str(target).startswith(str(SOURCES_DIR.resolve())):
raise HTTPException(status_code=400, detail="Path escapes base directory")
if not target.is_dir():
raise HTTPException(status_code=404, detail="Directory not found")
entries: list[FileEntry] = []
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
item_rel = item.relative_to(SOURCES_DIR)
entries.append(_file_entry(SOURCES_DIR, item_rel))
return entries
@files_router.post("/files/mkdir")
async def mkdir(req: MkdirRequest):
"""Create a folder in both SOURCES_DIR and PAGES_DIR."""
rel = _validate_relative_path(req.path)
(SOURCES_DIR / rel).mkdir(parents=True, exist_ok=True)
(PAGES_DIR / rel).mkdir(parents=True, exist_ok=True)
return {"created": str(rel)}
@files_router.post("/files/move")
async def move_file(req: MoveRequest):
"""Move/rename a file or folder in both SOURCES_DIR and PAGES_DIR."""
from_rel = _validate_relative_path(req.from_path)
to_rel = _validate_relative_path(req.to)
# Move in SOURCES_DIR
src_from = SOURCES_DIR / from_rel
src_to = SOURCES_DIR / to_rel
if src_from.exists():
src_to.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src_from), str(src_to))
# Move in PAGES_DIR (for .uf files, look for .mu counterpart)
if src_from.suffix == ".uf" or (not src_from.exists() and from_rel.suffix == ".uf"):
pages_from = PAGES_DIR / from_rel.with_suffix(".mu")
pages_to = PAGES_DIR / to_rel.with_suffix(".mu")
else:
pages_from = PAGES_DIR / from_rel
pages_to = PAGES_DIR / to_rel
if pages_from.exists():
pages_to.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(pages_from), str(pages_to))
return {"moved": {"from": str(from_rel), "to": str(to_rel)}}
@files_router.get("/files/env")
async def read_env():
"""Read the .env file from SOURCES_DIR root."""
env_path = SOURCES_DIR / ".env"
if env_path.is_file():
return {"content": env_path.read_text(encoding="utf-8")}
return {"content": ""}
@files_router.post("/files/env")
async def save_env(req: EnvRequest):
"""Save the .env file to SOURCES_DIR root."""
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
env_path = SOURCES_DIR / ".env"
env_path.write_text(req.content, encoding="utf-8")
return {"saved": True}

View File

@@ -29,14 +29,56 @@ def _indent(code: str, level: int = 1) -> str:
def _resolve_vars(text: str) -> str: def _resolve_vars(text: str) -> str:
"""Convert $var references to Python f-string expressions.""" """Convert $var references to Python f-string expressions.
First escapes literal braces (e.g. @color{ff0}{text} → @color{{ff0}}{{text}})
so they survive f-string evaluation, then replaces $var → {var}.
"""
import re
# Use a unique placeholder for $var refs, escape all braces, then restore
_PH = "\x00VAR"
counter = [0]
placeholders: dict[str, str] = {}
def stash_var(m: re.Match) -> str:
var = m.group(1)
if "." in var:
parts = var.split(".")
base = parts[0]
chain = "".join(f"['{p}']" for p in parts[1:])
expr = "{" + base + chain + "}"
else:
expr = "{" + var + "}"
key = f"{_PH}{counter[0]}\x00"
counter[0] += 1
placeholders[key] = expr
return key
# 1. Stash $var references with placeholders
result = re.sub(r'\$([a-zA-Z_][\w.]*)', stash_var, text)
# 2. Escape all remaining braces for f-string safety
result = result.replace("{", "{{").replace("}", "}}")
# 3. Restore $var placeholders (unescaped)
for key, expr in placeholders.items():
result = result.replace(key, expr)
return result
def _resolve_vars_code(text: str) -> str:
"""Convert $var references to bare Python identifiers.
Simple vars: $name → name
Dotted paths: $item.name → item['name'] (dict access)
"""
import re import re
# Replace $var.attr.attr with {var_attr_attr} and simple $var with {var}
def replace_var(m: re.Match) -> str: def replace_var(m: re.Match) -> str:
var = m.group(1) var = m.group(1)
# Replace dots with underscores for Python variable names if "." in var:
py_var = var.replace(".", "_") parts = var.split(".")
return "{" + py_var + "}" base = parts[0]
chain = "".join(f"['{p}']" for p in parts[1:])
return base + chain
return var
return re.sub(r'\$([a-zA-Z_][\w.]*)', replace_var, text) return re.sub(r'\$([a-zA-Z_][\w.]*)', replace_var, text)
@@ -67,14 +109,28 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
elif node.source_type == SourceType.JSON: elif node.source_type == SourceType.JSON:
lines.append(f"{ind}{var} = _read_json({node.command!r})") lines.append(f"{ind}{var} = _read_json({node.command!r})")
elif node.source_type == SourceType.PYTHON: elif node.source_type == SourceType.PYTHON:
# Restricted eval — only datetime/secrets modules available lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': _dt_cls, 'timedelta': timedelta, 'secrets': secrets, 'os': os, 'json': json}})")
lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': datetime, 'secrets': secrets}})")
elif node.source_type == SourceType.PARAM: elif node.source_type == SourceType.PARAM:
lines.append(f"{ind}{var} = _get_param({node.command!r})") lines.append(f"{ind}{var} = _get_param({node.command!r})")
elif node.source_type == SourceType.RNS: elif node.source_type == SourceType.RNS:
import shlex as _shlex import shlex as _shlex
safe_cmd = _shlex.quote(node.command) safe_cmd = _shlex.quote(node.command)
lines.append(f"{ind}{var} = _shell('rnstatus ' + shlex.quote({safe_cmd!r}), timeout={node.timeout})") lines.append(f"{ind}{var} = _shell('rnstatus ' + shlex.quote({safe_cmd!r}), timeout={node.timeout})")
elif node.source_type == SourceType.HTTP:
method = node.http_method or "GET"
url = _resolve_vars(node.command)
hdrs = _resolve_vars(node.http_headers) if node.http_headers else ""
body = _resolve_vars(node.http_body) if node.http_body else ""
if body:
lines.append(f"""{ind}{var} = _http(f'''{url}''', method={method!r}, body=f'''{body}''', headers=f'''{hdrs}''', timeout={node.timeout})""")
elif hdrs:
lines.append(f"""{ind}{var} = _http(f'''{url}''', method={method!r}, headers=f'''{hdrs}''', timeout={node.timeout})""")
else:
lines.append(f"{ind}{var} = _http({node.command!r}, method={method!r}, timeout={node.timeout})")
elif node.source_type == SourceType.SQLITE:
lines.append(f"{ind}{var} = _sqlite({node.command!r}, {node.query!r})")
elif node.source_type == SourceType.ENV:
lines.append(f"{ind}{var} = os.environ.get({node.command!r}, '')")
elif isinstance(node, CacheControl): elif isinstance(node, CacheControl):
lines.append(f"{ind}_cache_seconds = {node.seconds}") lines.append(f"{ind}_cache_seconds = {node.seconds}")
@@ -83,8 +139,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
lines.append(f"{ind}{node.state_name} = _load_state({node.path!r})") lines.append(f"{ind}{node.state_name} = _load_state({node.path!r})")
elif isinstance(node, IfBlock): elif isinstance(node, IfBlock):
cond = _resolve_vars(node.condition) cond = _resolve_vars_code(node.condition)
# Convert simple comparisons
cond = cond.replace("&&", " and ").replace("||", " or ") cond = cond.replace("&&", " and ").replace("||", " or ")
lines.append(f"{ind}if {cond}:") lines.append(f"{ind}if {cond}:")
if node.children: if node.children:
@@ -94,7 +149,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
lines.append(f"{ind} pass") lines.append(f"{ind} pass")
for elif_cond, elif_children in node.elif_branches: for elif_cond, elif_children in node.elif_branches:
ec = _resolve_vars(elif_cond).replace("&&", " and ").replace("||", " or ") ec = _resolve_vars_code(elif_cond).replace("&&", " and ").replace("||", " or ")
lines.append(f"{ind}elif {ec}:") lines.append(f"{ind}elif {ec}:")
if elif_children: if elif_children:
for child in elif_children: for child in elif_children:
@@ -108,7 +163,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
lines.extend(_emit_node(child, indent_level + 1)) lines.extend(_emit_node(child, indent_level + 1))
elif isinstance(node, ForLoop): elif isinstance(node, ForLoop):
iterable = _resolve_vars(node.iterable) iterable = _resolve_vars_code(node.iterable)
lines.append(f"{ind}for {node.var_name} in _iter({iterable}):") lines.append(f"{ind}for {node.var_name} in _iter({iterable}):")
if node.children: if node.children:
for child in node.children: for child in node.children:
@@ -146,13 +201,16 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
elif isinstance(node, Gauge): elif isinstance(node, Gauge):
label = _resolve_vars(node.label) label = _resolve_vars(node.label)
value = _resolve_vars(str(node.value)) if "$" in str(node.value) else str(node.value) raw_val = getattr(node, "_raw_value", None)
value = _resolve_vars(raw_val) if raw_val and "$" in raw_val else str(node.value)
raw_max = getattr(node, "_raw_max_val", None)
max_val = _resolve_vars(raw_max) if raw_max and "$" in raw_max else str(node.max_val)
extra = "" extra = ""
if node.warn is not None: if node.warn is not None:
extra += f" warn={node.warn}" extra += f" warn={node.warn}"
if node.crit is not None: if node.crit is not None:
extra += f" crit={node.crit}" extra += f" crit={node.crit}"
lines.append(f"{ind}_uf_source_parts.append(f'gauge \"{label}\" {value} {node.max_val} {node.bar_width}{extra}')") lines.append(f"{ind}_uf_source_parts.append(f'gauge \"{label}\" {value} {max_val} {node.bar_width}{extra}')")
elif isinstance(node, Status): elif isinstance(node, Status):
label = _resolve_vars(node.label) label = _resolve_vars(node.label)
@@ -221,6 +279,7 @@ def _build_script(uframe_import: str, page_logic: str, page_title: str, page_wid
"# Do not edit — regenerate with: uframe compile <source>.uf", "# Do not edit — regenerate with: uframe compile <source>.uf",
"", "",
"import os, sys, json, subprocess, datetime, secrets, shlex", "import os, sys, json, subprocess, datetime, secrets, shlex",
"from datetime import datetime as _dt_cls, timedelta",
"", "",
"# ─── Runtime Helpers ─────────────────────────────────────────", "# ─── Runtime Helpers ─────────────────────────────────────────",
"", "",
@@ -279,6 +338,42 @@ def _build_script(uframe_import: str, page_logic: str, page_title: str, page_wid
' return val.strip().splitlines()', ' return val.strip().splitlines()',
' return []', ' return []',
"", "",
'def _http(url, method="GET", body="", headers="", timeout=10):',
' """HTTP request, return response body (JSON parsed if possible)."""',
' import urllib.request, urllib.error',
' try:',
' data = body.encode("utf-8") if body else None',
' req = urllib.request.Request(url, data=data, method=method)',
' req.add_header("User-Agent", "uframe/1.0")',
' if body and not headers:',
' req.add_header("Content-Type", "application/json")',
' if headers:',
' for pair in headers.split(";"):',
' if ":" in pair:',
' k, v = pair.split(":", 1)',
' req.add_header(k.strip(), v.strip())',
' with urllib.request.urlopen(req, timeout=timeout) as resp:',
' raw = resp.read().decode("utf-8")',
' try:',
' return json.loads(raw)',
' except (json.JSONDecodeError, ValueError):',
' return raw.strip()',
' except Exception as e:',
' return {"error": str(e)}',
"",
'def _sqlite(db_path, query):',
' """Run a SQLite query, return list of dicts."""',
' import sqlite3',
' try:',
' conn = sqlite3.connect(db_path)',
' conn.row_factory = sqlite3.Row',
' cur = conn.execute(query)',
' rows = [dict(r) for r in cur.fetchall()]',
' conn.close()',
' return rows if len(rows) != 1 else rows[0]',
' except Exception as e:',
' return {"error": str(e)}',
"",
f"# ─── µFrame Compile ──────────────────────────────────────────", f"# ─── µFrame Compile ──────────────────────────────────────────",
"", "",
uframe_import, uframe_import,

View File

@@ -78,8 +78,9 @@ def emit_micron(grid: CharGrid, page_title: str = "") -> str:
if cur_style != _EMPTY_STYLE: if cur_style != _EMPTY_STYLE:
line_parts.append(_emit_style_close(cur_style)) line_parts.append(_emit_style_close(cur_style))
cur_style = _EMPTY_STYLE cur_style = _EMPTY_STYLE
# Open new link # Open new link — backtick enters formatting mode
line_parts.append("[") # where the parser recognizes `[` as link start
line_parts.append("`[")
in_link = link in_link = link
# Handle style transitions (not inside links — links handle their own style) # Handle style transitions (not inside links — links handle their own style)

View File

@@ -302,6 +302,9 @@ class SourceType(Enum):
PYTHON = auto() PYTHON = auto()
RNS = auto() RNS = auto()
PARAM = auto() PARAM = auto()
HTTP = auto()
SQLITE = auto()
ENV = auto()
@dataclass @dataclass
@@ -317,6 +320,12 @@ class Source(IRNode):
var_name: str = "" var_name: str = ""
source_type: SourceType = SourceType.SHELL source_type: SourceType = SourceType.SHELL
command: str = "" command: str = ""
# HTTP-specific
http_method: str = "GET"
http_body: str = ""
http_headers: str = ""
# SQLite-specific: command = db path, query = SQL
query: str = ""
timeout: int = 5 timeout: int = 5

View File

@@ -138,7 +138,7 @@ def _parse_list_style(s: str) -> ListStyle:
}.get(s.lower(), ListStyle.BULLET) }.get(s.lower(), ListStyle.BULLET)
def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: def _parse_line(keyword: str, args: list[str], line_num: int, raw_args: str = "") -> IRNode:
"""Parse a single line into an IR node based on the keyword.""" """Parse a single line into an IR node based on the keyword."""
if keyword == "page": if keyword == "page":
@@ -364,7 +364,7 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
# Dynamic features # Dynamic features
elif keyword == "let": elif keyword == "let":
# let name = "value" or let name = 1,2,3 # let name = "value" or let name = 1,2,3
raw = " ".join(args) raw = raw_args
eq = raw.find("=") eq = raw.find("=")
if eq != -1: if eq != -1:
var_name = raw[:eq].strip() var_name = raw[:eq].strip()
@@ -377,11 +377,10 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
elif keyword == "source": elif keyword == "source":
# source cpu : shell "grep 'cpu' /proc/stat" # source cpu : shell "grep 'cpu' /proc/stat"
# source name : type "command" # source name : type "command"
raw = " ".join(args) colon = raw_args.find(":")
colon = raw.find(":")
if colon != -1: if colon != -1:
var_name = raw[:colon].strip() var_name = raw_args[:colon].strip()
rest = raw[colon + 1:].strip() rest = raw_args[colon + 1:].strip()
parts = _split_args(rest) parts = _split_args(rest)
src_type_str = parts[0] if parts else "shell" src_type_str = parts[0] if parts else "shell"
command = parts[1] if len(parts) > 1 else "" command = parts[1] if len(parts) > 1 else ""
@@ -392,17 +391,38 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
"python": SourceType.PYTHON, "python": SourceType.PYTHON,
"rns": SourceType.RNS, "rns": SourceType.RNS,
"param": SourceType.PARAM, "param": SourceType.PARAM,
"http": SourceType.HTTP,
"sqlite": SourceType.SQLITE,
"env": SourceType.ENV,
}.get(src_type_str.lower(), SourceType.SHELL) }.get(src_type_str.lower(), SourceType.SHELL)
# Parse optional timeout # Parse optional params from remaining parts
timeout = 5 timeout = 5
for p in parts[2:]: http_method = "GET"
if p.startswith("timeout"): http_body = ""
http_headers = ""
query = ""
extra = parts[2:]
if src_type == SourceType.SQLITE and len(parts) > 2:
# sqlite "/path/db" "SELECT ..."
query = parts[2]
extra = parts[3:]
for p in extra:
if p.startswith("timeout="):
try: try:
timeout = int(p.split("=")[1]) if "=" in p else int(parts[parts.index(p) + 1]) timeout = int(p.split("=", 1)[1])
except (ValueError, IndexError): except (ValueError, IndexError):
pass pass
elif p.startswith("method="):
http_method = p.split("=", 1)[1].upper()
elif p.startswith("body="):
http_body = p.split("=", 1)[1]
elif p.startswith("headers="):
http_headers = p.split("=", 1)[1]
return Source(var_name=var_name, source_type=src_type, return Source(var_name=var_name, source_type=src_type,
command=command, timeout=timeout, source_line=line_num) command=command, timeout=timeout,
http_method=http_method, http_body=http_body,
http_headers=http_headers, query=query,
source_line=line_num)
else: else:
return Source(var_name=args[0] if args else "", source_line=line_num) return Source(var_name=args[0] if args else "", source_line=line_num)
@@ -817,7 +837,7 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
args = _split_args(arg_str) args = _split_args(arg_str)
# Parse this line into a node # Parse this line into a node
node = _parse_line(keyword, args, line_num) node = _parse_line(keyword, args, line_num, raw_args=arg_str)
# Pop stack back to find the parent (parent indent < this indent) # Pop stack back to find the parent (parent indent < this indent)
while stack and stack[-1][0] >= indent: while stack and stack[-1][0] >= indent:

View File

@@ -36,7 +36,7 @@ def test_if_block():
text "Critical"''' text "Critical"'''
result = uframe.compile(source) result = uframe.compile(source)
assert result.is_dynamic assert result.is_dynamic
assert "if {val} > 90:" in result.script assert "if val > 90:" in result.script
def test_for_loop(): def test_for_loop():
@@ -46,7 +46,7 @@ def test_for_loop():
text "$item"''' text "$item"'''
result = uframe.compile(source) result = uframe.compile(source)
assert result.is_dynamic assert result.is_dynamic
assert "for item in _iter({items}):" in result.script assert "for item in _iter(items):" in result.script
def test_let_variable(): def test_let_variable():
@@ -120,5 +120,5 @@ def test_codegen_complete_dashboard():
assert "#!/usr/bin/env python3" in script assert "#!/usr/bin/env python3" in script
assert "_cache_seconds = 0" in script assert "_cache_seconds = 0" in script
assert "_shell" in script assert "_shell" in script
assert "if {cpu} > 90:" in script assert "if cpu > 90:" in script
assert "uframe.compile" in script assert "uframe.compile" in script

View File

@@ -158,9 +158,11 @@ source peers : shell "rnstatus -j | python3 -c 'import sys,json; d=json.load(s
source motd : file "/etc/motd" source motd : file "/etc/motd"
source config : json "/home/node/.nomadnetwork/config.json" source config : json "/home/node/.nomadnetwork/config.json"
# Python expression — evaluated inline # Python expression — evaluated inline (available: datetime, timedelta, secrets, os, json)
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')" source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
source rand_hex : python "secrets.token_hex(4)" source rand_hex : python "secrets.token_hex(4)"
source uptime : python "str(timedelta(seconds=12345))"
source hostname : python "os.uname().nodename"
# RNS/Reticulum API — direct integration # RNS/Reticulum API — direct integration
source peer_list : rns "peers" source peer_list : rns "peers"
@@ -346,135 +348,95 @@ A `.uf` file with dynamic features compiles into a Python script
that: that:
1. Sets the shebang and cache header 1. Sets the shebang and cache header
2. Imports required modules 2. Imports required modules + the `uframe` package
3. Reads environment variables (form data) 3. Defines runtime helpers (`_shell`, `_read_file`, `_read_json`, etc.)
4. Executes source commands (shell, file, python, rns) 4. Executes source commands and evaluates conditionals/loops
5. Evaluates conditionals and loops 5. Dynamically builds a `.uf` source string with resolved variables
6. Renders the IR tree into a CharGrid 6. Compiles that source with `uframe.compile()` at runtime
7. Emits the CharGrid as Micron with style tags 7. Prints the resulting Micron to stdout
8. Prints to stdout
```python ```python
#!/usr/bin/env python3 #!/usr/bin/env python3
#!c=0 # Auto-generated by uFrame
# Auto-generated by µFrame from dashboard.uf # Do not edit — regenerate with: uframe compile <source>.uf
# Do not edit — regenerate with: uframe compile dashboard.uf
import os, sys, json, subprocess, datetime, secrets import os, sys, json, subprocess, datetime, secrets, shlex
from datetime import datetime as _dt_cls, timedelta
# ─── µFrame Runtime (embedded) ─────────────────────────────── # ─── Runtime Helpers ─────────────────────────────────────────
class CharGrid: def _shell(cmd, timeout=5):
"""2D character grid with style annotations."""
def __init__(self, width, height):
self.w = width
self.h = height
self.chars = [[' ']*width for _ in range(height)]
self.styles = [[None]*width for _ in range(height)]
def put(self, x, y, ch, style=None):
if 0 <= x < self.w and 0 <= y < self.h:
self.chars[y][x] = ch
self.styles[y][x] = style
def box(self, x, y, w, h, weight='light', title=None, title_style=None):
"""Draw a box with automatic border characters."""
# ... border drawing logic ...
def gauge(self, x, y, w, value, max_val, label=None,
warn=None, crit=None):
"""Render a horizontal gauge bar with threshold colors."""
pct = min(value / max_val, 1.0)
filled = int(w * pct)
for i in range(w):
ch = '█' if i < filled else '░'
fg = None
if crit and value >= crit: fg = 'f00'
elif warn and value >= warn: fg = 'ff0'
elif i < filled: fg = '0f0'
else: fg = '555'
self.put(x + i, y, ch, {'fg': fg})
# ... label and percentage ...
def sparkline(self, x, y, w, values):
"""Render braille sparkline from value array."""
# ... braille pattern generation ...
def emit_micron(self):
"""Scan grid and emit Micron with style tags."""
lines = []
for row_idx in range(self.h):
line = []
cur_style = None
for col_idx in range(self.w):
ch = self.chars[row_idx][col_idx]
st = self.styles[row_idx][col_idx]
if st != cur_style:
# Close previous style tags
if cur_style:
if cur_style.get('fg'): line.append('`f')
if cur_style.get('bold'): line.append('`!')
# Open new style tags
if st:
if st.get('bold'): line.append('`!')
if st.get('fg'): line.append(f'`F{st["fg"]}')
cur_style = st
line.append(ch)
# Close final style
if cur_style:
if cur_style.get('fg'): line.append('`f')
if cur_style.get('bold'): line.append('`!')
lines.append(''.join(line).rstrip())
return '\n'.join(lines)
# ─── Form Data ───────────────────────────────────────────────
def get_field(name, default=''):
"""Read submitted form field from environment."""
return os.environ.get(f'FIELD_{name}', default)
def get_param(name, default=''):
"""Read URL parameter."""
return os.environ.get(f'PARAM_{name}',
os.environ.get(f'var_{name}', default))
# ─── Data Sources ────────────────────────────────────────────
def shell(cmd):
"""Execute shell command, return stdout.""" """Execute shell command, return stdout."""
try: try:
return subprocess.check_output( return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip()
cmd, shell=True, timeout=5
).decode().strip()
except Exception: except Exception:
return '' return ""
# ─── Resolve Sources ───────────────────────────────────────── def _read_file(path):
"""Read file contents."""
# ...
cpu_pct = int(shell( def _read_json(path):
"grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'" """Read and parse JSON file."""
) or 0) # ...
mem_pct = int(shell(
"free | awk '/Mem/{print int($3/$2*100)}'"
) or 0)
uptime_str = shell("uptime -p")
peer_count = shell("rnstatus -j 2>/dev/null | python3 -c "
"'import sys,json; print(len(json.load(sys.stdin).get(\"peers\",[])))'")
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
# ─── Build Grid & Render ──────────────────────────────────── def _get_field(name, default=""):
"""Read submitted form field from environment."""
return os.environ.get(f"FIELD_{name}", default)
grid = CharGrid(66, 40) def _get_param(name, default=""):
"""Read URL parameter."""
return os.environ.get(f"PARAM_{name}",
os.environ.get(f"var_{name}", default))
# ... all the box(), gauge(), sparkline(), text() calls def _load_state(path):
# ... exactly as the layout engine would produce them ... """Load state from JSON file."""
# ...
# ─── Output ────────────────────────────────────────────────── def _save_state(path, data):
"""Save state to JSON file."""
# ...
print('#!c=0') # cache header: never cache def _iter(val):
print(grid.emit_micron()) """Make a value iterable for for-loops."""
# handles lists, dicts, newline-delimited strings
# ─── µFrame Compile ──────────────────────────────────────────
import uframe
# ─── Page Logic ──────────────────────────────────────────────
_cache_seconds = 0
_uf_source_parts = []
cpu_pct = eval('secrets.randbelow(60) + 20', {'datetime': _dt_cls, ...})
timestamp = eval("datetime.now().strftime('%H:%M:%S')", {'datetime': _dt_cls, ...})
_uf_source_parts.append(f'heading 1 "Resources"')
_uf_source_parts.append(f'gauge "CPU" {cpu_pct} 100 28 warn=75.0 crit=90.0')
_uf_source_parts.append(f'text "Updated: {timestamp}"')
if cpu_pct > 90:
_uf_source_parts.append(f'text "ALERT: CPU critical"')
# ─── Render & Output ─────────────────────────────────────────
_uf_source = f'''page "Live Status" 60
''' + "\n".join(_uf_source_parts)
result = uframe.compile(_uf_source, width=60)
if _cache_seconds >= 0:
print(f"#!c={_cache_seconds}")
print(result.micron)
``` ```
The key insight: the generated script **rebuilds `.uf` source** with
live data substituted in, then compiles it with the full µFrame
pipeline. This means every layout feature (boxes, gauges, tables,
sparklines) works identically in both static and dynamic pages.
### 4.2 CLI usage ### 4.2 CLI usage
```bash ```bash
@@ -644,30 +606,35 @@ sparkline renders identically in both ASCII preview and live Micron.
page "Status" 64 page "Status" 64
cache 0 cache 0
source cpu : shell "cat /proc/loadavg | awk '{print int($1*100/$(nproc))}'" source cpu : python "secrets.randbelow(60) + 20"
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'" source mem : python "secrets.randbelow(40) + 50"
source net_in : shell "net_traffic.sh in" source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
source net_out : shell "net_traffic.sh out" source timestamp : python "datetime.now().strftime('%H:%M:%S')"
source net_history_in : shell "net_spark.sh in 20"
source net_history_out : shell "net_spark.sh out 20"
box heavy "System Status" box heavy "System Status"
row 2 row 2
gauge "CPU" $cpu 100 28 warn=75 crit=90 gauge "CPU" $cpu 100 28 warn=75 crit=90
gauge "MEM" $mem 100 28 warn=80 crit=95 gauge "MEM" $mem 100 28 warn=80 crit=95
spacer spacer
label "IN" "$net_in KB/s" label "Uptime" "$uptime"
sparkline "IN" $net_history_in 28 label "Updated" "$timestamp"
label "OUT" "$net_out KB/s"
sparkline "OUT" $net_history_out 28
text "@center{@italic{Press Ctrl+R to refresh}}" text "@center{@italic{Press Ctrl+R to refresh}}"
``` ```
Client hits the page → script runs → reads `/proc` → renders Client hits the page → script runs → evaluates sources →
gauges and sparklines with real data → client sees it. renders gauges with live data → client sees it.
Ctrl+R re-requests → fresh execution → updated values. Ctrl+R re-requests → fresh execution → updated values.
On a full Linux node, replace the python sources with shell commands
to read real system data:
```
source cpu : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'"
source uptime : shell "uptime -p"
```
### 6.2 Guestbook with Persistent State ### 6.2 Guestbook with Persistent State
``` ```

View File

@@ -160,6 +160,59 @@ export async function fetchRemotePage(
return res.json(); return res.json();
} }
// ---------------------------------------------------------------------------
// File Browser
// ---------------------------------------------------------------------------
export interface FileEntry {
name: string;
path: string;
type: "file" | "folder" | "env";
size: number | null;
last_modified: number | null;
title: string | null;
published: boolean;
}
export async function fetchFiles(path: string = ""): Promise<FileEntry[]> {
const params = path ? `?path=${encodeURIComponent(path)}` : "";
const res = await fetch(`/api/files${params}`);
return res.json();
}
export async function createFolder(path: string): Promise<void> {
const res = await fetch("/api/files/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function moveFile(from: string, to: string): Promise<void> {
const res = await fetch("/api/files/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from, to }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function fetchEnv(): Promise<string> {
const res = await fetch("/api/files/env");
const data = await res.json();
return data.content;
}
export async function saveEnv(content: string): Promise<void> {
const res = await fetch("/api/files/env", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error(await res.text());
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Images // Images
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -410,9 +410,9 @@ export const EXAMPLES: Example[] = [
source: `page "Live Status" 60 source: `page "Live Status" 60
cache 0 cache 0
source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'" source cpu_pct : python "secrets.randbelow(60) + 20"
source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'" source mem_pct : python "secrets.randbelow(40) + 50"
source uptime : shell "uptime -p" source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
source timestamp : python "datetime.now().strftime('%H:%M:%S')" source timestamp : python "datetime.now().strftime('%H:%M:%S')"
box double "Node Monitor" box double "Node Monitor"

View File

@@ -1,6 +1,9 @@
/** /**
* Micron markup → HTML renderer using the micron-parser library. * Micron markup → HTML renderer using the micron-parser library.
* Reference: https://github.com/RFnexus/micron-parser-js * Reference: https://github.com/RFnexus/micron-parser-js
*
* Uses convertMicronToFragment (DOM-based) instead of convertMicronToHtml
* to avoid DOMPurify stripping nomadnetwork:// hrefs from link tags.
*/ */
import MicronParser from "micron-parser"; import MicronParser from "micron-parser";
@@ -9,11 +12,14 @@ let darkParser: MicronParser | null = null;
let lightParser: MicronParser | null = null; let lightParser: MicronParser | null = null;
export function renderMicron(source: string, darkTheme: boolean = true): string { export function renderMicron(source: string, darkTheme: boolean = true): string {
if (darkTheme) { const parser = darkTheme
if (!darkParser) darkParser = new MicronParser(true, true); ? (darkParser ??= new MicronParser(true, true))
return darkParser.convertMicronToHtml(source); : (lightParser ??= new MicronParser(false, true));
} else {
if (!lightParser) lightParser = new MicronParser(false, true); const fragment = parser.convertMicronToFragment(source);
return lightParser.convertMicronToHtml(source);
} // Serialize the fragment to HTML string
const div = document.createElement("div");
div.appendChild(fragment);
return div.innerHTML;
} }

View File

@@ -101,7 +101,7 @@ const frameLayout: FrameLayout = {
containerMaxW: "max-w-5xl", containerMaxW: "max-w-5xl",
containerMinW: "min-w-5xl", containerMinW: "min-w-5xl",
title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 }, title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 },
content: { marginLeft: 268, marginTop: 63, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 }, content: { marginLeft: 268, marginTop: 63, marginRight: 0, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 },
nav: { top: 150, left: -210 }, nav: { top: 150, left: -210 },
}; };

View File

@@ -178,11 +178,20 @@ function buildGraphArrays(
// Browse window data // Browse window data
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface HistoryEntry {
path: string;
html: string | null;
error: string | null;
}
interface BrowseWinData { interface BrowseWinData {
node: NetworkNode; node: NetworkNode;
pageHtml: string | null; pageHtml: string | null;
pageLoading: boolean; pageLoading: boolean;
pageError: string | null; pageError: string | null;
currentPath: string;
history: HistoryEntry[];
historyIndex: number;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -485,15 +494,83 @@ export default function BrowseView() {
return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); }; return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); };
}, []); }, []);
// ── Navigation helpers ──
const navigateTo = useCallback((winId: string, node: NetworkNode, path: string, prevData?: BrowseWinData) => {
const loading: BrowseWinData = {
node, pageHtml: null, pageLoading: true, pageError: null,
currentPath: path,
history: prevData?.history ?? [],
historyIndex: prevData?.historyIndex ?? -1,
};
updateWindow(winId, { data: loading });
fetchRemotePage(node.hash, path)
.then((res) => {
const html = res.content ? renderMicron(res.content, true) : null;
const error = res.content ? null : (res.error ?? "No content");
const entry: HistoryEntry = { path, html, error };
// Build new history: truncate any forward entries, push new
const prevHistory = loading.history.slice(0, loading.historyIndex + 1);
const newHistory = [...prevHistory, entry];
const newIndex = newHistory.length - 1;
updateWindow(winId, { data: { node, pageHtml: html, pageError: error, pageLoading: false, currentPath: path, history: newHistory, historyIndex: newIndex } });
})
.catch((e) => {
const error = String(e);
const entry: HistoryEntry = { path, html: null, error };
const prevHistory = loading.history.slice(0, loading.historyIndex + 1);
const newHistory = [...prevHistory, entry];
const newIndex = newHistory.length - 1;
updateWindow(winId, { data: { node, pageError: error, pageLoading: false, pageHtml: null, currentPath: path, history: newHistory, historyIndex: newIndex } });
});
}, [updateWindow]);
const navBack = useCallback((winId: string, data: BrowseWinData) => {
const newIndex = data.historyIndex - 1;
if (newIndex < 0) return;
const entry = data.history[newIndex]!;
updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } });
}, [updateWindow]);
const navForward = useCallback((winId: string, data: BrowseWinData) => {
const newIndex = data.historyIndex + 1;
if (newIndex >= data.history.length) return;
const entry = data.history[newIndex]!;
updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } });
}, [updateWindow]);
const navReload = useCallback((winId: string, data: BrowseWinData) => {
navigateTo(winId, data.node, data.currentPath, { ...data, historyIndex: data.historyIndex - 1 });
}, [navigateTo]);
// ── Node click ── // ── Node click ──
const handleNodeClick = useCallback((node: NetworkNode) => { const handleNodeClick = useCallback((node: NetworkNode) => {
const id = node.hash; const id = node.hash;
const data: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null }; const initData: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null, currentPath: "index.mu", history: [], historyIndex: -1 };
openWindow(id, data); openWindow(id, initData);
fetchRemotePage(node.hash) navigateTo(id, node, "index.mu");
.then((res) => updateWindow(id, { data: { node, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } })) }, [openWindow, navigateTo]);
.catch((e) => updateWindow(id, { data: { node, pageError: String(e), pageLoading: false, pageHtml: null } }));
}, [openWindow, updateWindow]); // ── Handle micron link clicks via event delegation ──
const handleContentClick = useCallback((e: React.MouseEvent, winId: string, data: BrowseWinData) => {
const anchor = (e.target as HTMLElement).closest("a");
if (!anchor) return;
e.preventDefault();
const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? "";
if (!dest) return;
// Strip nomadnetwork:// prefix if present, normalize path
let path = dest.replace(/^nomadnetwork:\/\//, "").replace(/^\/+/, "");
// If it looks like a hash (hex, 32 chars), it's a node link — not a page path
if (/^[0-9a-f]{32}$/i.test(path)) return;
// Ensure .mu extension
if (!path.endsWith(".mu")) path += ".mu";
navigateTo(winId, data.node, path, data);
}, [navigateTo]);
const clearSearch = useCallback(() => { const clearSearch = useCallback(() => {
setFilter(""); setFilter("");
@@ -502,8 +579,6 @@ export default function BrowseView() {
updateClusterLabels(); updateClusterLabels();
}, [updateClusterLabels]); }, [updateClusterLabels]);
const nodeCount = nodes.filter(n => n.type !== "interface").length;
const ifaceCount = nodes.filter(n => n.type === "interface").length;
// ── Capture typing into search when no window is focused ── // ── Capture typing into search when no window is focused ──
useEffect(() => { searchInputRef.current?.focus(); }, []); useEffect(() => { searchInputRef.current?.focus(); }, []);
@@ -612,7 +687,11 @@ export default function BrowseView() {
</div> </div>
)} )}
{windows.map((win) => ( {windows.map((win) => {
const d = win.data;
const canBack = d.historyIndex > 0;
const canFwd = d.historyIndex < d.history.length - 1;
return (
<FloatingWindow <FloatingWindow
key={win.id} key={win.id}
id={win.id} id={win.id}
@@ -624,30 +703,47 @@ export default function BrowseView() {
onClose={closeWindowById} onClose={closeWindowById}
onFocus={focusWindow} onFocus={focusWindow}
addressBar={ addressBar={
<div className="flex items-center gap-2 px-3 py-1 border-b border-border shrink-0 bg-muted/15"> <div className="flex items-center gap-1.5 px-2 py-1 border-b border-border shrink-0 bg-muted/15">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">addr</span> {/* Nav buttons */}
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate">{win.data.node.hash}</div> <button onClick={() => navBack(win.id, d)} disabled={!canBack || d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Back">
&#9664;
</button>
<button onClick={() => navForward(win.id, d)} disabled={!canFwd || d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Forward">
&#9654;
</button>
<button onClick={() => navReload(win.id, d)} disabled={d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Reload">
&#8635;
</button>
{/* Address */}
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate">
<span className="text-muted-foreground/60 truncate">{d.node.hash.slice(0, 12)}/</span>{d.currentPath}
</div>
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0"> <span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
{win.data.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span> {d.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
: win.data.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></> : d.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
: win.data.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null} : d.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
</span> </span>
</div> </div>
} }
footer={ footer={
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg"> <div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{win.data.node.type ?? "peer"}</span> <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{d.node.type ?? "peer"}</span>
{win.data.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {win.data.node.interface}</span>} {d.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {d.node.interface}</span>}
</div> </div>
} }
> >
<div className="p-3 h-full overflow-auto"> {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
{win.data.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>} <div className="p-3 h-full overflow-auto" onClick={(e) => handleContentClick(e, win.id, d)}>
{win.data.pageError && <span className="text-destructive text-xs">{win.data.pageError}</span>} {d.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
{win.data.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: win.data.pageHtml }} />} {d.pageError && <span className="text-destructive text-xs">{d.pageError}</span>}
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
</div> </div>
</FloatingWindow> </FloatingWindow>
))} );
})}
</div> </div>
); );
} }

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { MoreVertical, Plus, RotateCcw } from "lucide-react"; import { MoreVertical, Plus, FolderPlus, ChevronRight, Folder, FileText, KeyRound, ArrowLeft, ArrowUp, ArrowDown } from "lucide-react";
import { usePagesStore } from "@/stores/pagesStore"; import { usePagesStore } from "@/stores/pagesStore";
import { restartNode } from "@/api/client"; import * as api from "@/api/client";
import StatusBadge from "@/components/dashboard/StatusBadge"; import StatusBadge from "@/components/dashboard/StatusBadge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -30,39 +30,104 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { useWindowManager } from "@/hooks/useWindowManager"; import { useWindowManager } from "@/hooks/useWindowManager";
import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow"; import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow";
import EditorPane from "@/components/editor/EditorPane";
import EditorPointer from "@/components/editor/EditorPointer";
import FloatingWindow from "@/components/shared/FloatingWindow";
import type { ManagedWindow } from "@/hooks/useWindowManager";
// ---------------------------------------------------------------------------
// Env editor window data
// ---------------------------------------------------------------------------
interface EnvWinData {
kind: "env";
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function ComposeView() { export default function ComposeView() {
const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } = const { deletePage, publishPage, unpublishPage } = usePagesStore();
usePagesStore();
// File browser state
const [currentPath, setCurrentPath] = useState("");
const [files, setFiles] = useState<api.FileEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pageToDelete, setPageToDelete] = useState<string | null>(null); const [pageToDelete, setPageToDelete] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false); const [newFolderName, setNewFolderName] = useState("");
const { windows, focusedId, open, update, close, focus } = useWindowManager<EditorWinData>({ w: 720, h: 520 }); const [showNewFolder, setShowNewFolder] = useState(false);
const [sortKey, setSortKey] = useState<"name" | "size" | "modified">("name");
const [sortAsc, setSortAsc] = useState(true);
const sortedFiles = useMemo(() => {
// Folders always first, then sort within each group
const folders = files.filter(f => f.type === "folder");
const rest = files.filter(f => f.type !== "folder");
const cmp = (a: api.FileEntry, b: api.FileEntry): number => {
let v = 0;
if (sortKey === "name") v = a.name.localeCompare(b.name);
else if (sortKey === "size") v = (a.size ?? 0) - (b.size ?? 0);
else if (sortKey === "modified") v = (a.last_modified ?? 0) - (b.last_modified ?? 0);
return sortAsc ? v : -v;
};
folders.sort(cmp);
rest.sort(cmp);
return [...folders, ...rest];
}, [files, sortKey, sortAsc]);
const toggleSort = (key: "name" | "size" | "modified") => {
if (sortKey === key) setSortAsc(!sortAsc);
else { setSortKey(key); setSortAsc(true); }
};
// Editor windows
const { windows: editorWindows, focusedId: editorFocused, open: openEditorWin, update: updateEditorWin, close: closeEditorWin, focus: focusEditorWin } = useWindowManager<EditorWinData>({ w: 720, h: 520 });
// Env editor windows
const { windows: envWindows, focusedId: envFocused, open: openEnvWin, update: updateEnvWin, close: closeEnvWin, focus: focusEnvWin } = useWindowManager<EnvWinData>({ w: 520, h: 400 });
const loadFiles = useCallback(async (path: string = currentPath) => {
setIsLoading(true);
try {
const entries = await api.fetchFiles(path);
setFiles(entries);
} finally {
setIsLoading(false);
}
}, [currentPath]);
useEffect(() => { loadFiles(currentPath); }, [currentPath]);
const navigateTo = (path: string) => setCurrentPath(path);
const navigateUp = () => {
if (!currentPath) return;
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
setCurrentPath(parts.join("/"));
};
// Path breadcrumbs
const pathParts = currentPath ? currentPath.split("/").filter(Boolean) : [];
const openEditor = (name: string, isNew: boolean) => { const openEditor = (name: string, isNew: boolean) => {
const id = isNew ? `new-${Date.now()}` : name; // For files in subfolders, use full relative path as page name
open(id, { pageName: isNew ? "" : name, isNew }); const pageName = isNew ? "" : name;
const id = isNew ? `new-${Date.now()}` : pageName;
openEditorWin(id, { pageName, isNew });
}; };
useEffect(() => { const openEnvEditor = () => {
fetchPages(); openEnvWin("env-editor", { kind: "env" });
}, []);
const handleRestart = async () => {
setRestarting(true);
try {
await restartNode();
toast.success("NomadNet restarted");
} catch (e) {
toast.error(`Restart failed: ${e}`);
} finally {
setRestarting(false);
}
}; };
const handlePublish = async (name: string) => { const handlePublish = async (name: string) => {
try { try {
await publishPage(name); await publishPage(name);
toast.success(`"${name}" published`); toast.success(`"${name}" published`);
loadFiles();
} catch (e) { } catch (e) {
toast.error(`Failed: ${e}`); toast.error(`Failed: ${e}`);
} }
@@ -72,6 +137,7 @@ export default function ComposeView() {
try { try {
await unpublishPage(name); await unpublishPage(name);
toast.success(`"${name}" unpublished`); toast.success(`"${name}" unpublished`);
loadFiles();
} catch (e) { } catch (e) {
toast.error(`Failed: ${e}`); toast.error(`Failed: ${e}`);
} }
@@ -79,28 +145,63 @@ export default function ComposeView() {
const handleDelete = async () => { const handleDelete = async () => {
if (!pageToDelete) return; if (!pageToDelete) return;
await deletePage(pageToDelete); // Extract stem from path for the pages API
const stem = pageToDelete.replace(/\.uf$/, "");
await deletePage(stem);
toast.success(`"${pageToDelete}" deleted`); toast.success(`"${pageToDelete}" deleted`);
setPageToDelete(null); setPageToDelete(null);
loadFiles();
}; };
if (isLoading) const handleCreateFolder = async () => {
return ( const name = newFolderName.trim();
<div className="flex items-center justify-center h-full text-muted-foreground"> if (!name) return;
Loading... const folderPath = currentPath ? `${currentPath}/${name}` : name;
</div> try {
); await api.createFolder(folderPath);
toast.success(`Folder "${name}" created`);
setNewFolderName("");
setShowNewFolder(false);
loadFiles();
} catch (e) {
toast.error(`Failed: ${e}`);
}
};
const handleFileClick = (entry: api.FileEntry) => {
if (entry.type === "folder") {
navigateTo(entry.path);
} else if (entry.type === "env") {
openEnvEditor();
} else {
// Open .uf file in editor — strip .uf extension for page name
const pageName = entry.path.replace(/\.uf$/, "");
openEditor(pageName, false);
}
};
const formatSize = (size: number | null) => {
if (size == null) return "\u2014";
if (size < 1024) return `${size} B`;
return `${(size / 1024).toFixed(1)} KB`;
};
const formatTime = (ts: number | null) => {
if (ts == null) return "\u2014";
const d = new Date(ts * 1000);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
};
return ( return (
<div> <div className="flex flex-col h-full">
<div> <div className="flex flex-col flex-1 min-h-0">
{/* Header row */} {/* Header row */}
<div className="flex items-center px-2 py-1.5 border-b-2 border-border"> <div className="flex items-center px-2 py-1.5 border-b-2 border-border">
<h1 className="text-xs font-semibold flex-1">Compose</h1> <h1 className="text-xs font-semibold flex-1">Compose</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleRestart} disabled={restarting}> <Button variant="outline" size="sm" onClick={() => setShowNewFolder(true)}>
<RotateCcw className="w-3 h-3 mr-1.5" /> <FolderPlus className="w-3 h-3 mr-1.5" />
Restart New Folder
</Button> </Button>
<Button size="sm" onClick={() => openEditor("", true)}> <Button size="sm" onClick={() => openEditor("", true)}>
<Plus className="w-3 h-3 mr-1.5" /> <Plus className="w-3 h-3 mr-1.5" />
@@ -109,64 +210,135 @@ export default function ComposeView() {
</div> </div>
</div> </div>
{/* Table */} {/* Path bar */}
<div className="flex items-center px-3 py-1.5 border-b border-border bg-muted/15 text-xs">
{currentPath && (
<button onClick={navigateUp} className="mr-2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<ArrowLeft className="w-3.5 h-3.5" />
</button>
)}
<button onClick={() => navigateTo("")} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer font-mono">
/
</button>
{pathParts.map((part, i) => {
const partPath = pathParts.slice(0, i + 1).join("/");
return (
<span key={partPath} className="flex items-center">
<ChevronRight className="w-3 h-3 mx-0.5 text-muted-foreground/50" />
<button onClick={() => navigateTo(partPath)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer font-mono">
{part}
</button>
</span>
);
})}
{/* New folder inline input */}
{showNewFolder && (
<span className="flex items-center ml-4 gap-1">
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<input
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateFolder();
if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); }
}}
placeholder="folder name"
className="h-5 px-1.5 text-xs bg-background border border-border rounded font-mono w-32 focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button onClick={handleCreateFolder} className="text-primary text-xs cursor-pointer">create</button>
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="text-muted-foreground text-xs cursor-pointer">cancel</button>
</span>
)}
</div>
{/* File table */}
<div className="flex-1 min-h-0 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">Loading...</div>
) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <SortableHead label="Name" sortKey="name" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
<TableHead>Title</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Size</TableHead> <SortableHead label="Size" sortKey="size" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
<SortableHead label="Modified" sortKey="modified" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
<TableHead className="w-8" /> <TableHead className="w-8" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{pages.map((p) => ( {sortedFiles.map((entry) => (
<TableRow <TableRow
key={p.name} key={entry.path}
className="cursor-pointer" className="cursor-pointer"
onClick={() => openEditor(p.name, false)} onClick={() => handleFileClick(entry)}
> >
<TableCell className="font-mono"> <TableCell className="font-mono">
{p.name} <span className="flex items-center gap-2">
{p.name === "index" && ( {entry.type === "folder" ? (
<span className="ml-2 text-xs text-primary">homepage</span> <Folder className="w-3.5 h-3.5 text-primary/70 shrink-0" />
) : entry.type === "env" ? (
<KeyRound className="w-3.5 h-3.5 text-amber-500/70 shrink-0" />
) : (
<FileText className="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
)} )}
</TableCell> <span>{entry.name}</span>
<TableCell className="text-muted-foreground"> {entry.name === "index.uf" && (
{p.title ?? "\u2014"} <span className="text-[10px] text-primary">homepage</span>
)}
</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusBadge published={p.published} hasSource={p.has_source} /> {entry.type === "file" && entry.name.endsWith(".uf") ? (
<StatusBadge published={entry.published} hasSource={true} />
) : null}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{p.size != null ? `${p.size} B` : "\u2014"} {entry.type !== "folder" ? formatSize(entry.size) : "\u2014"}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTime(entry.last_modified)}
</TableCell> </TableCell>
<TableCell className="text-right w-8"> <TableCell className="text-right w-8">
<PageActions {entry.type === "file" && entry.name.endsWith(".uf") && (
published={p.published} <FileActions
onEdit={() => openEditor(p.name, false)} entry={entry}
onPublish={() => handlePublish(p.name)} folders={files.filter(f => f.type === "folder")}
onUnpublish={() => handleUnpublish(p.name)} currentPath={currentPath}
onDelete={() => setPageToDelete(p.name)} onEdit={() => handleFileClick(entry)}
onPublish={() => handlePublish(entry.path.replace(/\.uf$/, ""))}
onUnpublish={() => handleUnpublish(entry.path.replace(/\.uf$/, ""))}
onDelete={() => setPageToDelete(entry.path)}
onMove={async (to) => {
try {
await api.moveFile(entry.path, to);
toast.success(`Moved "${entry.name}" to ${to || "/"}`);
loadFiles();
} catch (e) {
toast.error(`Move failed: ${e}`);
}
}}
/> />
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
{pages.length === 0 && ( {files.length === 0 && (
<TableRow> <TableRow>
<TableCell <TableCell colSpan={5} className="text-center text-muted-foreground py-8">
colSpan={5} {currentPath ? "Empty folder." : "No files yet. Create a page to get started."}
className="text-center text-muted-foreground py-8"
>
No pages yet. Create one to get started.
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
)}
</div>
</div> </div>
{/* Delete confirmation */}
<AlertDialog <AlertDialog
open={pageToDelete !== null} open={pageToDelete !== null}
onOpenChange={(open) => !open && setPageToDelete(null)} onOpenChange={(open) => !open && setPageToDelete(null)}
@@ -175,8 +347,7 @@ export default function ComposeView() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle> <AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This permanently deletes the page and its source. This cannot be This permanently deletes the file and its published version. This cannot be undone.
undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -191,15 +362,27 @@ export default function ComposeView() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* Floating editor windows */} {/* Editor windows */}
{windows.map((win) => ( {editorWindows.map((win) => (
<EditorWindow <EditorWindow
key={win.id} key={win.id}
win={win} win={win}
focused={focusedId === win.id} focused={editorFocused === win.id}
onUpdate={update} onUpdate={updateEditorWin}
onClose={close} onClose={closeEditorWin}
onFocus={focus} onFocus={focusEditorWin}
/>
))}
{/* Env editor windows */}
{envWindows.map((win) => (
<EnvEditorWindow
key={win.id}
win={win}
focused={envFocused === win.id}
onUpdate={updateEnvWin}
onClose={closeEnvWin}
onFocus={focusEnvWin}
/> />
))} ))}
</div> </div>
@@ -207,20 +390,40 @@ export default function ComposeView() {
} }
/** Per-row action menu for a page. */ // ---------------------------------------------------------------------------
function PageActions({ // File action menu
published, // ---------------------------------------------------------------------------
function FileActions({
entry,
folders,
currentPath,
onEdit, onEdit,
onPublish, onPublish,
onUnpublish, onUnpublish,
onDelete, onDelete,
onMove,
}: { }: {
published: boolean; entry: api.FileEntry;
folders: api.FileEntry[];
currentPath: string;
onEdit: () => void; onEdit: () => void;
onPublish: () => void; onPublish: () => void;
onUnpublish: () => void; onUnpublish: () => void;
onDelete: () => void; onDelete: () => void;
onMove: (to: string) => void;
}) { }) {
const [showMove, setShowMove] = useState(false);
// Build move targets: parent dir (if in a subfolder) + sibling folders
const moveTargets: { label: string; path: string }[] = [];
if (currentPath) {
moveTargets.push({ label: "/ (root)", path: entry.name });
}
for (const f of folders) {
moveTargets.push({ label: f.name + "/", path: f.path + "/" + entry.name });
}
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
@@ -233,12 +436,12 @@ function PageActions({
</button> </button>
} }
/> />
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1"> <PopoverContent side="bottom" align="end" sideOffset={4} className="w-44 p-1">
<button <button
onClick={(e) => { e.stopPropagation(); onEdit(); }} onClick={(e) => { e.stopPropagation(); onEdit(); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer" className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Edit</button> >Edit</button>
{published ? ( {entry.published ? (
<button <button
onClick={(e) => { e.stopPropagation(); onUnpublish(); }} onClick={(e) => { e.stopPropagation(); onUnpublish(); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer" className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
@@ -249,6 +452,31 @@ function PageActions({
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer" className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Publish</button> >Publish</button>
)} )}
{moveTargets.length > 0 && (
<>
<button
onClick={(e) => { e.stopPropagation(); setShowMove(!showMove); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer flex items-center justify-between"
>
Move to
<ChevronRight className={`w-3 h-3 transition-transform ${showMove ? "rotate-90" : ""}`} />
</button>
{showMove && (
<div className="border-t border-border mt-0.5 pt-0.5">
{moveTargets.map((t) => (
<button
key={t.path}
onClick={(e) => { e.stopPropagation(); onMove(t.path); }}
className="w-full text-left px-4 py-1.5 text-xs font-mono text-muted-foreground hover:text-foreground hover:bg-accent transition-colors cursor-pointer flex items-center gap-1.5"
>
<Folder className="w-3 h-3 shrink-0" />
{t.label}
</button>
))}
</div>
)}
</>
)}
<button <button
onClick={(e) => { e.stopPropagation(); onDelete(); }} onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer" className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer"
@@ -257,3 +485,134 @@ function PageActions({
</Popover> </Popover>
); );
} }
// ---------------------------------------------------------------------------
// Sortable table header
// ---------------------------------------------------------------------------
function SortableHead({ label, sortKey, currentKey, asc, onToggle }: {
label: string;
sortKey: "name" | "size" | "modified";
currentKey: string;
asc: boolean;
onToggle: (key: "name" | "size" | "modified") => void;
}) {
const active = currentKey === sortKey;
return (
<TableHead>
<button
onClick={(e) => { e.stopPropagation(); onToggle(sortKey); }}
className="flex items-center gap-1 text-inherit hover:text-foreground transition-colors cursor-pointer"
>
{label}
{active && (asc
? <ArrowUp className="w-3 h-3" />
: <ArrowDown className="w-3 h-3" />
)}
</button>
</TableHead>
);
}
// ---------------------------------------------------------------------------
// Env editor floating window
// ---------------------------------------------------------------------------
function EnvEditorWindow({
win,
focused,
onUpdate,
onClose,
onFocus,
}: {
win: ManagedWindow<EnvWinData>;
focused: boolean;
onUpdate: (id: string, patch: Partial<ManagedWindow<EnvWinData>>) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
}) {
const windowRef = useRef<HTMLDivElement>(null);
const [content, setContent] = useState("");
const [isDirty, setIsDirty] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.fetchEnv().then((c) => setContent(c));
}, []);
const handleChange = useCallback((v: string) => {
setContent(v);
setIsDirty(true);
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
try {
await api.saveEnv(content);
setIsDirty(false);
toast.success(".env saved");
} catch (e) {
toast.error(`Save failed: ${e}`);
} finally {
setSaving(false);
}
}, [content]);
useEffect(() => {
if (!focused) return;
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [focused, handleSave]);
const handleClose = useCallback((id: string) => {
if (isDirty && !window.confirm("You have unsaved changes. Close anyway?")) return;
onClose(id);
}, [isDirty, onClose]);
return (
<FloatingWindow
id={win.id}
title=".env"
x={win.x} y={win.y} w={win.w} h={win.h}
zIndex={win.zIndex}
focused={focused}
onUpdate={onUpdate}
onClose={handleClose}
onFocus={onFocus}
minW={360} minH={200}
containerRef={windowRef}
>
<EditorPointer containerRef={windowRef} focused={focused} />
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center px-3 py-1.5 border-b-2 border-border shrink-0 gap-2">
<KeyRound className="w-3.5 h-3.5 text-amber-500/70" />
<span className="text-xs font-semibold flex-1">Environment Variables</span>
<button
onClick={handleSave}
disabled={saving || !isDirty}
className="text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors cursor-pointer"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
{/* Help */}
<div className="px-3 py-1.5 border-b border-border bg-muted/10 text-[10px] text-muted-foreground">
One variable per line: <span className="font-mono">KEY=value</span>. Use <span className="font-mono">source name : env "KEY"</span> in pages.
</div>
{/* CodeMirror editor */}
<div className="flex-1 min-h-0">
<EditorPane value={content} onChange={handleChange} />
</div>
</div>
</FloatingWindow>
);
}