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:
parent
a11d030edd
commit
2ab54a3800
4 changed files with 44 additions and 24 deletions
|
|
@ -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:**
|
||||||
|
|
|
||||||
|
|
@ -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 100–500 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 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.
|
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>
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue