Source code for exlab_wizard.ui.pages.template_editor

"""Template authoring editor page (Frontend Spec §5 GUI).

The author-time counterpart to :mod:`exlab_wizard.ui.pages.templates`: where
the manager lists / scaffolds templates, this page opens *one* template
directory and lets the operator edit its ``copier.yml`` (questions), edit its
inline text files, upload binary / rendered files, and delete files. Every
disk mutation lives in the callbacks (wired in :mod:`exlab_wizard.ui.mount`)
that delegate to the :mod:`exlab_wizard.template.authoring` service; this
module only *reads* the template (manifest + file list + lint findings) to
render and routes operator actions back to those callbacks.

:func:`render_template_editor` follows the same pure-view-with-payload
contract as :func:`exlab_wizard.ui.pages.templates.render_template_manager`:
when NiceGUI is unavailable it returns a plain payload dict (so the view is
unit-testable headless); otherwise it builds the NiceGUI widget tree. Every
widget carries a ``data-testid`` for the browser e2e suite.
"""

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING, Any

from exlab_wizard.logging import get_logger
from exlab_wizard.template.authoring import list_files, read_content, read_manifest
from exlab_wizard.template.lint import has_errors, lint_template
from exlab_wizard.template.manifest import TemplateManifest, TemplateQuestion

if TYPE_CHECKING:
    from pathlib import Path

__all__ = ["render_template_editor"]

_log = get_logger(__name__)

# The Copier question kinds the editor's type ``ui.select`` offers. ``choice``
# is the editor-level pseudo-kind that ``TemplateQuestion`` normalises to (it
# round-trips through ``copier.yml`` as ``type: str`` + a ``choices`` block).
_QUESTION_KINDS: tuple[str, ...] = ("str", "int", "float", "bool", "choice")


def _build_payload(template_dir: Path) -> dict[str, Any]:
    """Read ``template_dir`` and return the headless render payload.

    Loads the manifest + file list + lint findings via the authoring /
    lint services. Used directly as the NiceGUI-unavailable return value
    and as the data source for the NiceGUI branch, so the two stay in
    lock-step. Tolerant of an unreadable manifest (falls back to empty
    metadata) so a broken template still renders its lint banner.
    """
    try:
        manifest, _stat = read_manifest(template_dir)
    except Exception as exc:  # a broken manifest must still render its lint banner
        _log.warning("template editor: failed to read manifest for %s: %s", template_dir, exc)
        manifest = None

    files = list_files(template_dir)
    findings = lint_template(template_dir)
    return {
        "template": template_dir.name,
        "files": [entry["rel"] for entry in files],
        "questions": [q.key for q in (manifest.questions if manifest else [])],
        "findings": [f.code for f in findings],
        "exlab_type": manifest.exlab_type if manifest else "",
        "run_scope": manifest.exlab_run_scope if manifest else None,
    }


[docs] def render_template_editor( *, template_dir: Path, on_save_manifest: Callable[[TemplateManifest], None] | None = None, on_save_content: Callable[[str, str], None] | None = None, on_upload: Callable[[str, bytes, bool], None] | None = None, on_delete: Callable[[str], None] | None = None, on_back: Callable[[], None] | None = None, ) -> Any: """Render the single-template authoring editor. The view loads ``template_dir`` (manifest, file list, lint findings) and renders a header + lint banner, a file list with per-row edit / delete actions, an inline text editor, a question form, and an upload widget. All disk mutation is delegated to the callbacks: * ``on_save_manifest(manifest)`` -- persist a rebuilt :class:`~exlab_wizard.template.manifest.TemplateManifest` (the question form preserves the loaded manifest's non-question ``_exlab_*`` fields and replaces only its questions). * ``on_save_content(rel, text)`` -- write an edited text file. * ``on_upload(filename, data, render_as_template)`` -- store an upload. * ``on_delete(rel)`` -- remove a file. * ``on_back()`` -- leave the editor. Every callback is optional; when ``None`` the corresponding action is a no-op so the function still renders (and returns its payload headless) without raising. Returns: The NiceGUI root card, or -- when NiceGUI is unavailable -- the headless payload dict from :func:`_build_payload`. """ payload = _build_payload(template_dir) try: from nicegui import ui except Exception: return payload # Re-read structured data for the rich render (the payload only carries # the headless projection). A broken manifest falls back to an empty one # so the lint banner + file list still render. try: manifest, _stat = read_manifest(template_dir) except Exception as exc: # render the banner even if the manifest is unreadable _log.warning("template editor: manifest unreadable for render: %s", exc) manifest = TemplateManifest(exlab_type="", exlab_version="") findings = lint_template(template_dir) entries = list_files(template_dir) card = ( ui.card() .props('data-testid="te-card"') .style( "min-width: 820px; margin: 2rem auto; padding: var(--sp-6); " "background: var(--color-surface); border-radius: var(--radius-md);" ) ) with card: _render_header(ui, template_dir.name) _render_lint_banner(ui, findings) _render_file_list(ui, template_dir, entries, on_save_content, on_delete) _render_question_form(ui, manifest, on_save_manifest) _render_upload(ui, on_upload) if on_back is not None: with ( ui.row() .classes("items-center w-full justify-end") .style("gap: var(--sp-3); padding-top: var(--sp-4);") ): ui.button("Back", on_click=lambda _evt: on_back()).props( 'flat data-testid="te-back"' ) return card
# --------------------------------------------------------------------------- # NiceGUI section renderers (only reached when NiceGUI imports cleanly) # --------------------------------------------------------------------------- def _render_header(ui: Any, name: str) -> None: """Render the template-name header.""" ui.label(f"Editing template: {name}").props('data-testid="te-title"').style( "font-family: var(--font-display); font-size: var(--text-lg); " "font-weight: 600; color: var(--color-heading);" ) def _render_lint_banner(ui: Any, findings: list[Any]) -> None: """Render the lint banner: errors red, warnings amber, else a clean note.""" errors = [f for f in findings if f.severity == "error"] warns = [f for f in findings if f.severity == "warn"] if has_errors(findings): banner = ui.column().props('data-testid="te-lint-errors"') with banner: ui.label(f"{len(errors)} error(s) -- template will not load:").style( "color: var(--color-danger, #d33); font-weight: 600;" ) for finding in errors: ui.label(f"• {finding.message}").style("color: var(--color-danger, #d33);") if warns: banner = ui.column().props('data-testid="te-lint-warns"') with banner: ui.label(f"{len(warns)} warning(s):").style( "color: var(--color-warning, #b8860b); font-weight: 600;" ) for finding in warns: ui.label(f"• {finding.message}").style("color: var(--color-warning, #b8860b);") if not findings: ui.label("No lint findings.").props('data-testid="te-lint-clean"').style( "color: var(--color-muted);" ) def _render_file_list( ui: Any, template_dir: Path, entries: list[dict], on_save_content: Callable[[str, str], None] | None, on_delete: Callable[[str], None] | None, ) -> None: """Render the file list with per-row edit / delete actions + inline editor. Each editable text file gets an Edit button that loads its content into a shared inline editor (``ui.codemirror`` if available, else ``ui.textarea``); the editor's Save button calls ``on_save_content``. Every non-``copier.yml`` row gets a Delete button calling ``on_delete``. """ ui.label("Files").style("font-weight: 600; padding-top: var(--sp-3);") # Shared inline editor + its state, declared up front so the per-row # Edit handlers can target it. The editor widget is whichever of # codemirror / textarea NiceGUI provides. editor_state: dict[str, Any] = {"rel": None} editor_factory = getattr(ui, "codemirror", None) or ui.textarea editor = editor_factory(value="").props('data-testid="te-editor"') editor.style("width: 100%; min-height: 12rem; display: none;") def _open(rel: str) -> None: path = template_dir / rel try: text, _stat = read_content(path) except Exception as exc: # surface unreadable files instead of crashing the page _log.warning("template editor: cannot open %s: %s", rel, exc) return editor_state["rel"] = rel editor.value = text editor.style("width: 100%; min-height: 12rem; display: block;") def _save() -> None: rel = editor_state["rel"] if rel is None or on_save_content is None: return on_save_content(rel, editor.value or "") for entry in entries: rel = entry["rel"] with ( ui.row() .classes("items-center w-full") .props(f'data-testid="te-file-{rel}"') .style("gap: var(--sp-2);") ): label = rel + ("/" if entry["is_dir"] else "") ui.label(label).style("color: var(--color-body); flex: 1;") if entry["editable"]: ui.button("Edit", on_click=lambda _evt, r=rel: _open(r)).props( f'flat dense data-testid="te-edit-{rel}"' ) if not entry["is_dir"]: ui.button( "Delete", on_click=lambda _evt, r=rel: on_delete and on_delete(r), ).props(f'flat dense color=negative data-testid="te-delete-{rel}"') ui.button("Save file", on_click=lambda _evt: _save()).props( 'color=primary data-testid="te-save-content"' ) def _render_question_form( ui: Any, manifest: TemplateManifest, on_save_manifest: Callable[[TemplateManifest], None] | None, ) -> None: """Render the editable question form bound to the manifest's questions. Each question is one row of widgets (key / kind / default / help / choices / secret). Add / Remove buttons grow / shrink the row list; Save Questions rebuilds a :class:`TemplateManifest` -- preserving every non-question ``_exlab_*`` field from ``manifest`` and replacing only its questions -- and calls ``on_save_manifest``. """ ui.label("Questions").style("font-weight: 600; padding-top: var(--sp-3);") # Each row's widgets are stashed in a dict so Save can read their values # back. ``rows`` is the live working set; Remove drops a row's widgets. rows: list[dict[str, Any]] = [] container = ui.column().props('data-testid="te-questions"').style("width: 100%;") def _add_row(question: TemplateQuestion | None = None) -> None: with container: row = ui.row().classes("items-center w-full").style("gap: var(--sp-2);") with row: key_w = ui.input(label="Key", value=(question.key if question else "")).props( 'dense data-testid="te-q-key"' ) kind_w = ui.select( list(_QUESTION_KINDS), value=(question.kind if question else "str"), label="Type", ).props('dense data-testid="te-q-kind"') default_w = ui.input( label="Default", value=( "" if question is None or question.default is None else str(question.default) ), ).props('dense data-testid="te-q-default"') help_w = ui.input(label="Help", value=(question.help if question else "")).props( 'dense data-testid="te-q-help"' ) choices_w = ui.input( label="Choices (comma-separated)", value=(", ".join(str(c) for c in question.choices) if question else ""), ).props('dense data-testid="te-q-choices"') secret_w = ui.checkbox( "Secret", value=(bool(question.secret) if question else False) ).props('data-testid="te-q-secret"') record = { "row": row, "key": key_w, "kind": kind_w, "default": default_w, "help": help_w, "choices": choices_w, "secret": secret_w, } def _remove(_evt: Any = None, rec: dict[str, Any] = record) -> None: rec["row"].delete() if rec in rows: rows.remove(rec) with row: ui.button("Remove", on_click=_remove).props( 'flat dense color=negative data-testid="te-q-remove"' ) rows.append(record) for existing in manifest.questions: _add_row(existing) def _save() -> None: if on_save_manifest is None: return questions = [_question_from_row(rec) for rec in rows] questions = [q for q in questions if q.key] rebuilt = TemplateManifest( exlab_type=manifest.exlab_type, exlab_version=manifest.exlab_version, exlab_run_scope=manifest.exlab_run_scope, description=manifest.description, plugins=list(manifest.plugins), readme_fields=list(manifest.readme_fields), questions=questions, min_copier_version=manifest.min_copier_version, answers_file=manifest.answers_file, ) on_save_manifest(rebuilt) with ( ui.row().classes("items-center w-full").style("gap: var(--sp-3); padding-top: var(--sp-2);") ): ui.button("Add question", on_click=lambda _evt: _add_row()).props( 'flat data-testid="te-q-add"' ) ui.button("Save questions", on_click=lambda _evt: _save()).props( 'color=primary data-testid="te-save-questions"' ) def _question_from_row(rec: dict[str, Any]) -> TemplateQuestion: """Build a :class:`TemplateQuestion` from one form row's widget values. The default string is coerced toward the selected kind (int / float / bool) so a round-trip through ``copier.yml`` keeps the declared type; an uncoercible value falls back to the raw string. ``choice`` questions split the comma-text choices field; other kinds carry no choices. """ key = (rec["key"].value or "").strip() kind = rec["kind"].value or "str" raw_default = rec["default"].value or "" help_text = rec["help"].value or "" secret = bool(rec["secret"].value) choices: tuple[Any, ...] = () if kind == "choice": choices = tuple( piece.strip() for piece in str(rec["choices"].value or "").split(",") if piece.strip() ) default = _coerce_default(raw_default, kind) return TemplateQuestion( key=key, kind=kind, default=default, choices=choices, help=help_text, secret=secret ) def _coerce_default(raw: str, kind: str) -> Any: """Coerce a string default toward ``kind`` (``None`` for an empty string).""" text = raw.strip() if not text: return None try: if kind == "int": return int(text) if kind == "float": return float(text) if kind == "bool": return text.lower() in {"1", "true", "yes", "on"} except ValueError: return text return text def _render_upload(ui: Any, on_upload: Callable[[str, bytes, bool], None] | None) -> None: """Render the upload widget + a "render as template" checkbox. ``ui.upload`` is guarded (not yet a hard dependency in this codebase); when absent the section is skipped. On upload the handler reads the NiceGUI upload event's bytes and calls ``on_upload(name, data, flag)``. """ ui.label("Upload file").style("font-weight: 600; padding-top: var(--sp-3);") render_flag = ui.checkbox("Render as template (.jinja)", value=False).props( 'data-testid="te-upload-render"' ) upload_widget = getattr(ui, "upload", None) if upload_widget is None: ui.label("Upload unavailable in this build.").props('data-testid="te-upload-unavailable"') return def _on_event(event: Any) -> None: if on_upload is None: return name = getattr(event, "name", "") or "" content = getattr(event, "content", None) data = content.read() if content is not None and hasattr(content, "read") else b"" on_upload(name, data, bool(render_flag.value)) upload_widget(on_upload=_on_event, auto_upload=True).props('data-testid="te-upload"')