fix(l4d2-web): file tree fetches in plain JS — vendored htmx is a stub

The vendored static/vendor/htmx.min.js turned out to be a 33-byte
placeholder, so the hx-get/hx-target/hx-trigger attributes on the
overlay file tree's folder buttons were inert: clicks rotated the
chevron (own JS) but never fetched. Switch the lazy-load to a
~30-line plain-JS handler in static/js/file-tree.js that fetches
button.dataset.filesUrl on first expand and dedupes via dataset.loaded.
Update the spec/plan to match. Route + partial contracts unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-08 20:23:04 +02:00
parent a11d030edd
commit 2ab54a3800
No known key found for this signature in database
4 changed files with 44 additions and 24 deletions

View file

@ -15,7 +15,7 @@ See the design doc for rationale. Implementation-relevant summary:
- New blueprint `files_bp` registered in `l4d2web/app.py` next to `overlay_bp`. - New blueprint `files_bp` registered in `l4d2web/app.py` next to `overlay_bp`.
- Path resolution chains through `l4d2host.paths.overlay_path()` (already validates the overlay ref + resolves under `LEFT4ME_ROOT/overlays/`) and `l4d2web.services.security.validate_overlay_ref` (rejects empty/`.`/`..`/absolute/whitespace/backslash for the sub-path component). - Path resolution chains through `l4d2host.paths.overlay_path()` (already validates the overlay ref + resolves under `LEFT4ME_ROOT/overlays/`) and `l4d2web.services.security.validate_overlay_ref` (rejects empty/`.`/`..`/absolute/whitespace/backslash for the sub-path component).
- Listing rule: target must be a descendant of `overlay_root` after `Path.resolve()`. Download rule: real path must be a descendant of `LEFT4ME_ROOT` after `os.path.realpath()`. - Listing rule: target must be a descendant of `overlay_root` after `Path.resolve()`. Download rule: real path must be a descendant of `LEFT4ME_ROOT` after `os.path.realpath()`.
- Tree shape: single recursive partial. `_overlay_file_tree.html` renders `<ul>`; `_overlay_file_node.html` renders one folder or file `<li>`. Folder buttons carry `hx-get`, `hx-target="next .file-tree-children"`, `hx-swap="innerHTML"`, `hx-trigger="click once"`. Subsequent toggles handled by `static/js/file-tree.js`. - Tree shape: single recursive partial. `_overlay_file_tree.html` renders `<ul>`; `_overlay_file_node.html` renders one folder or file `<li>`. Folder buttons carry `data-files-url="/overlays/{id}/files?path=…"`. `static/js/file-tree.js` handles every click — toggles `aria-expanded` + `hidden`, fetches once on first expand, dedupes rapid clicks via `dataset.loaded`.
- `DEFAULT_MAX_ENTRIES = 500` in the helper module; re-resolved per call so tests can monkeypatch. - `DEFAULT_MAX_ENTRIES = 500` in the helper module; re-resolved per call so tests can monkeypatch.
- No changes to `l4d2host`, builders, or workshop/script edit flows. - No changes to `l4d2host`, builders, or workshop/script edit flows.
@ -119,13 +119,13 @@ pytest l4d2web/tests/ -q # no regressions across the full suite
**Files:** **Files:**
- Create: `l4d2web/static/js/file-tree.js`12-line event-delegated `click` handler that toggles `aria-expanded` on `.file-tree-toggle` and `hidden` on the next `.file-tree-children` sibling. - Create: `l4d2web/static/js/file-tree.js` — event-delegated `click` handler that toggles `aria-expanded` on `.file-tree-toggle` and `hidden` on the next `.file-tree-children` sibling, and on first expand fires `fetch(button.dataset.filesUrl)` and innerHTMLs the response. `dataset.loaded` flag dedupes rapid clicks; cleared on error to allow retry.
- Modify: `l4d2web/templates/base.html``<script src="{{ url_for('static', filename='js/file-tree.js') }}"></script>` next to the existing `csrf.js` / `sse.js` / `modal.js` lines. - Modify: `l4d2web/templates/base.html``<script src="{{ url_for('static', filename='js/file-tree.js') }}"></script>` next to the existing `csrf.js` / `sse.js` / `modal.js` lines.
- Modify: `l4d2web/static/css/components.css` — append `~50` lines: `.file-tree`, `.file-tree-row`, `.file-tree-toggle` (transparent button, inherits color), `.file-tree-toggle .chevron` rotation transform on `aria-expanded="true"`, `.file-tree-children[hidden]`, `.file-tree-badge` + `.file-tree-badge-warn`. All against existing tokens (`--space-xs`, `--space-l`, `--color-surface-muted`, `--color-muted`, `--color-danger`, `--radius-s`). - Modify: `l4d2web/static/css/components.css` — append `~50` lines: `.file-tree`, `.file-tree-row`, `.file-tree-toggle` (transparent button, inherits color), `.file-tree-toggle .chevron` rotation transform on `aria-expanded="true"`, `.file-tree-children[hidden]`, `.file-tree-badge` + `.file-tree-badge-warn`. All against existing tokens (`--space-xs`, `--space-l`, `--color-surface-muted`, `--color-muted`, `--color-danger`, `--radius-s`).
**Implementation:** **Implementation:**
- `hx-trigger="click once"` makes HTMX fire the GET only on the first expansion; the JS handler fires on every click and toggles aria/hidden state. First click: HTMX populates `.file-tree-children`, JS toggles to expanded. Subsequent clicks: only JS fires, no re-fetch. - The JS handler fires on every click. First-expand path: read `button.dataset.filesUrl`, set `dataset.loaded="1"` optimistically, `fetch(url, {credentials: "same-origin"})`, replace `.file-tree-children` innerHTML with the response. Subsequent clicks just toggle `aria-expanded` + `hidden` — no re-fetch since `dataset.loaded` is set. On fetch error: `delete dataset.loaded` so a future click retries.
- The CSS chevron is a Unicode `` inside a `<span class="chevron">`; rotated 90° on expanded via `transform: rotate(90deg)` with a 120ms transition. - The CSS chevron is a Unicode `` inside a `<span class="chevron">`; rotated 90° on expanded via `transform: rotate(90deg)` with a 120ms transition.
**Verification:** **Verification:**

View file

@ -1,8 +1,8 @@
# Overlay File Tree Section Design # Overlay File Tree Section Design
**Goal:** Add a "Files" section to the overlay detail page (`/overlays/<id>`) 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. **Goal:** Add a "Files" section to the overlay detail page (`/overlays/<id>`) that renders a collapsible tree of the overlay's runtime directory at `${LEFT4ME_ROOT}/overlays/{overlay.id}/`, with lazy expansion of folders (one fetch per first-time expand) 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. **Approval status:** User-approved 2026-05-08 (visual companion brainstorm + plan-mode review). Implemented + deployed in the same session. The lazy-load originally targeted HTMX (vendored in `base.html`), but the post-deploy smoke uncovered that `static/vendor/htmx.min.js` was a 33-byte placeholder — the real library was never vendored. Rather than vendoring full HTMX for one feature, the lazy-load was switched to plain JS using the same fetch + innerHTML pattern (~30 lines in `static/js/file-tree.js`). The route + partial contracts are unchanged.
## Context ## Context
@ -10,13 +10,11 @@ Today, the overlay detail page shows the row's metadata (name, type, scope, path
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. 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 ## 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. 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/<id>/files?path=<rel>` and swaps in that folder's children. The HTMX-disabled / no-JS path still shows the root level (the same partial is server-rendered). 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/<id>/files?path=<rel>` and innerHTMLs the response into that folder's `.file-tree-children` div. The no-JS path still shows the root level (the same partial is server-rendered) — folders just won't expand.
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. 3. **Single delegated JS handler.** `static/js/file-tree.js` listens for `click` on `document`, finds the closest `.file-tree-toggle` button, toggles `aria-expanded` + `hidden`, and on first expand fires a `fetch()` against the URL in the button's `data-files-url`. Subsequent toggles never re-fetch (`button.dataset.loaded` flag, set optimistically before the fetch to dedupe rapid clicks; cleared on error to allow retry).
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 100500 MB and that's the whole point. 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 100500 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. 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`). 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`).
@ -60,15 +58,15 @@ GET /overlays/<id>/files/download?path=<rel> (files_routes.overlay_files_d
### File tree fragment shape ### File tree fragment shape
`_overlay_file_tree.html` produces a `<ul class="file-tree">` containing one `_overlay_file_node.html` row per entry plus an optional truncated-footer `<li>`. A folder row is a `<button class="file-tree-toggle">` (HTMX attributes for the GET) followed by an empty `<div class="file-tree-children" hidden>` that becomes the swap target. A file row is a `<a href=".../files/download?path=…">` (or a plain `<span>` for broken symlinks) plus optional badges (`link`, `broken link`) and the resolved size. `_overlay_file_tree.html` produces a `<ul class="file-tree">` containing one `_overlay_file_node.html` row per entry plus an optional truncated-footer `<li>`. A folder row is a `<button class="file-tree-toggle" data-files-url="…">` followed by an empty `<div class="file-tree-children" hidden>` that becomes the fetch target. A file row is an `<a href=".../files/download?path=…">` (or a plain `<span>` for broken symlinks) plus optional badges (`link`, `broken link`) and the resolved size.
Nesting after expand: Nesting after expand:
```html ```html
<li class="file-tree-row file-tree-row-dir"> <li class="file-tree-row file-tree-row-dir">
<button class="file-tree-toggle" aria-expanded="true" hx-get="…" ></button> <button class="file-tree-toggle" aria-expanded="true" data-files-url="…"></button>
<div class="file-tree-children"> <div class="file-tree-children">
<ul class="file-tree" role="group"></ul> <!-- inserted by HTMX --> <ul class="file-tree" role="group"></ul> <!-- inserted by file-tree.js -->
</div> </div>
</li> </li>
``` ```

View file

@ -1,15 +1,40 @@
// Toggle expand/collapse for file-tree folder rows. HTMX handles the // Lazy-loaded collapsible file tree on overlay detail pages.
// initial fetch (hx-trigger="click once"); this script handles every //
// subsequent click without re-fetching. // One delegated click handler on document. Each `.file-tree-toggle` button
// carries `data-files-url`. First expand fires a fetch and innerHTMLs the
// returned partial into the next `.file-tree-children`; subsequent clicks
// just toggle visibility — no re-fetch.
(function () { (function () {
document.addEventListener("click", function (event) { document.addEventListener("click", function (event) {
const button = event.target.closest(".file-tree-toggle"); const button = event.target.closest(".file-tree-toggle");
if (!button) return; if (!button) return;
const expanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", expanded ? "false" : "true");
const children = button.nextElementSibling; const children = button.nextElementSibling;
if (children && children.classList.contains("file-tree-children")) { if (!children || !children.classList.contains("file-tree-children")) return;
children.hidden = expanded;
} const wasExpanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", wasExpanded ? "false" : "true");
children.hidden = wasExpanded;
if (wasExpanded) return; // collapsing — nothing to fetch
if (button.dataset.loaded === "1") return; // already populated
const url = button.dataset.filesUrl;
if (!url) return;
button.dataset.loaded = "1"; // optimistic — prevents duplicate fetches on rapid clicks
fetch(url, { headers: { Accept: "text/html" }, credentials: "same-origin" })
.then(function (response) {
if (!response.ok) throw new Error("HTTP " + response.status);
return response.text();
})
.then(function (html) {
children.innerHTML = html;
})
.catch(function (err) {
delete button.dataset.loaded; // allow retry
children.innerHTML =
'<p class="muted">Failed to load directory contents.</p>';
console.error("file-tree:", err);
});
}); });
})(); })();

View file

@ -3,10 +3,7 @@
<button type="button" <button type="button"
class="file-tree-toggle" class="file-tree-toggle"
aria-expanded="false" aria-expanded="false"
hx-get="/overlays/{{ overlay.id }}/files?path={{ entry.rel|urlencode }}" data-files-url="/overlays/{{ overlay.id }}/files?path={{ entry.rel|urlencode }}">
hx-target="next .file-tree-children"
hx-swap="innerHTML"
hx-trigger="click once">
<span class="chevron" aria-hidden="true"></span>{{ entry.name }}/ <span class="chevron" aria-hidden="true"></span>{{ entry.name }}/
</button> </button>
<div class="file-tree-children" hidden></div> <div class="file-tree-children" hidden></div>