# Overlay File Tree Section Design **Goal:** Add a "Files" section to the overlay detail page (`/overlays/`) that renders a collapsible tree of the overlay's runtime directory at `${LEFT4ME_ROOT}/overlays/{overlay.id}/`, with HTMX-driven lazy expansion of folders and click-to-download for individual files. Same access rule as the rest of the overlay detail page (admin or `overlay.user_id == g.user.id`). Read-only; no rename/delete/upload in v1. **Approval status:** User-approved 2026-05-08 (visual companion brainstorm + plan-mode review). Implemented + deployed in the same session. ## Context Today, the overlay detail page shows the row's metadata (name, type, scope, path, last build status), a workshop-items table or script editor depending on `overlay.type`, and links to the build-job stream. It never shows what's actually inside the overlay directory on disk. To verify "did my script actually produce what I expected?" or "did the right VPKs land in `addons/`?" the user has to SSH into the host and `ls /var/lib/left4me/overlays/{id}/`. Click-to-download is a secondary nice-to-have: workshop overlays' `addons/*.vpk` are absolute symlinks into the shared `${LEFT4ME_ROOT}/workshop_cache/`, and pulling a single VPK to a dev box otherwise means scping with the right path translation. This is also the codebase's **first HTMX-driven feature**: HTMX is vendored in `base.html` but not previously used in any template. The route + partial + tiny event-delegated JS established here become the reusable pattern for future HTMX features. ## Locked Decisions 1. **All overlay types show the section.** Script + workshop + system/managed. Consistency over a tighter scope; even workshop's predictable `addons/*.vpk` layout is worth confirming. 2. **Collapsible tree, lazy load on expand.** Tree can get large; only the root level is rendered server-side at first paint. Each folder click fires `GET /overlays//files?path=` and swaps in that folder's children. The HTMX-disabled / no-JS path still shows the root level (the same partial is server-rendered). 3. **`hx-trigger="click once"` + JS for re-toggle.** First click does the GET (HTMX disables further fetches via `once`); every subsequent click runs through `static/js/file-tree.js`, an event-delegated handler that toggles `aria-expanded` on the button and `hidden` on the next `.file-tree-children` sibling. 4. **Single-file download in v1.** No bulk archive (e.g., "download whole overlay as `.tar.gz`"). Files are streamed via Flask `send_file(..., as_attachment=True)`. No size cap — VPKs are commonly 100–500 MB and that's the whole point. 5. **No auto-refresh.** The tree reflects what was on disk at page render. After a build, the user reloads the page. Polling/SSE would duplicate the existing live-log mechanism on the build-job page for negligible benefit. 6. **Same access rule as the rest of the page.** `g.user.admin or overlay.user_id is None or overlay.user_id == g.user.id`. GETs need no CSRF (`l4d2web/app.py:56`). 7. **`overlay.path` not `overlay.id`.** The runtime directory is reached via `overlay.path` (current creation flow guarantees `path == str(id)`, but legacy/seeded rows may differ). Path resolution happens through the existing `l4d2host.paths.overlay_path()` helper, which already validates the ref string and resolves+verifies it stays under `${LEFT4ME_ROOT}/overlays/`. 8. **Empty / unresolvable → empty state.** If the overlay's path is unresolvable (legacy absolute-path rows) or the directory doesn't exist (overlay never built), the section renders "No files yet — build this overlay to populate it." rather than crashing. 9. **500-entry cap per folder.** Folders with more than 500 children render the alphabetical-first 500 plus a `+ M more (truncated)` footer. Tunable at runtime via `l4d2web.services.overlay_files.DEFAULT_MAX_ENTRIES` (re-resolved per call so tests can monkeypatch). 10. **Hidden files shown.** No filtering of `.git`, `.DS_Store`, etc. Users want ground truth. 11. **One dedicated blueprint, `files_bp`.** Not folded into `overlay_routes.py` (which is exclusively POST mutations) or `page_routes.py` (top-level pages, not embedded fragments). `files_bp` owns both the tree fragment and the download endpoint. ## Architecture ```text GET /overlays/ (page_routes.overlay_detail) │ ▼ computes (file_tree_root_entries, truncated_count) via │ _root_file_tree(overlay) → safe_resolve_for_listing(overlay.path, "") │ → list_directory(overlay_root, overlay_root) │ ▼ renders overlay_detail.html, which includes _overlay_file_tree.html for the root level (or the empty-state

). GET /overlays//files?path= (files_routes.overlay_files_fragment) │ ▼ auth gate (admin or owner) │ ▼ safe_resolve_for_listing(overlay.path, rel) → Path under overlay_root │ ▼ list_directory(target, overlay_root) → entries[], truncated_count │ ▼ renders _overlay_file_tree.html (partial only — no base.html) GET /overlays//files/download?path= (files_routes.overlay_files_download) │ ▼ auth gate │ ▼ safe_resolve_for_download(overlay.path, rel) → real Path under LEFT4ME_ROOT │ (follows symlinks; allows targets anywhere in LEFT4ME_ROOT, e.g. workshop_cache) │ ▼ Flask send_file(real, as_attachment=True, download_name=basename(real)) ``` ### File tree fragment shape `_overlay_file_tree.html` produces a `