commit c3dd867870170f79124d75f0ca980d5dc5cf51eb
parent 9cc01a2127afae7d1ef7ee7ae141341e3e61b566
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Sun, 11 Oct 2026 14:29:49 +0530
Merge pull request #42 from notamitgamer/fast-tree
Instant folder navigation from a locally cached file tree
Diffstat:
22 files changed, 1407 insertions(+), 139 deletions(-)
diff --git a/.env.example b/.env.example
@@ -45,3 +45,7 @@ VPN_ALLOWLIST=
TRUST_CF_CONNECTING_IP=0
# Number of trusted reverse proxies in front of the app (X-Forwarded-For hops).
TRUSTED_PROXY_HOPS=1
+
+# --- Instant folder navigation ----------------------------------------------
+# Above this many files /api/tree is not shipped and the feature turns itself off.
+FAST_TREE_MAX_FILES=20000
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
@@ -33,7 +33,10 @@ jobs:
with:
node-version: "22"
- - name: Prefetch script unit tests
+ - name: Install JS test dependencies (not part of the app)
+ run: npm install --no-save --no-package-lock jsdom@30.1.2 @hotwired/turbo@8.0.4
+
+ - name: JS tests (prefetcher, instant folders, jsdom + real Turbo)
run: node --test tests/*.test.js
- name: Server fast-path tests (HTML caching + gzip, skipped HEAD, single-flight listings)
@@ -41,6 +44,12 @@ jobs:
HF_BUCKET_ID: ci/placeholder
run: python tests/test_server_fastpaths.py
+ - name: Instant-folder tests (local pages == server pages, endpoints, end to end with Turbo)
+ env:
+ HF_BUCKET_ID: ci/placeholder
+ REQUIRE_E2E: "1"
+ run: python tests/test_fast_tree.py
+
- name: Smoke test (app imports, static pages render)
env:
HF_BUCKET_ID: ci/placeholder
@@ -54,7 +63,7 @@ jobs:
m.repo_stats = no_stats
c = TestClient(m.app)
- for path in ["/new", "/static/manifest.json", "/static/prefetch.js"]:
+ for path in ["/new", "/static/manifest.json", "/static/prefetch.js", "/static/fasttree.js"]:
r = c.get(path)
assert r.status_code == 200, (path, r.status_code)
print(path, r.status_code)
diff --git a/.gitignore b/.gitignore
@@ -23,3 +23,6 @@ android/.gradle/
.idea/
.vscode/
.DS_Store
+
+# JS test dependency, installed on demand (npm install --no-save jsdom)
+node_modules/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
@@ -35,7 +35,9 @@ CI runs these on every pull request; please run them locally first:
pip install ruff
ruff check --select E9,F63,F7,F82 app # syntax errors and undefined names
python -m compileall -q app
-node --test tests/*.test.js # unit tests for the browser-side prefetcher (Node 20+)
+npm install --no-save --no-package-lock jsdom@30.1.2 @hotwired/turbo@8.0.4 # test-only; nothing is added to the repo
+node --test tests/*.test.js # JS unit + jsdom tests (prefetcher, instant folders), Node 20+
+HF_BUCKET_ID=ci/x python tests/test_fast_tree.py # a locally built folder page must equal the server's
HF_BUCKET_ID=ci/x python tests/test_server_fastpaths.py # offline checks for the caching/gzip/listing fast paths
docker build -t cdnv2 . # the image must still build
```
@@ -68,3 +70,11 @@ validation, rate limiting), adding a test alongside it is very much appreciated.
This project is licensed under the [MIT License](LICENSE). By submitting a contribution you agree that
it is licensed under the same terms, and that you have the right to submit it.
+
+## Changing the folder page markup
+
+The folder listing's HTML lives in `app/templates/_listing.html` (Jinja macros) and is used both by the
+server and, via `/api/shell`, by the browser's instant-folder navigation. Edit the macros there, not
+inline in `index.html`. If you add something that depends on the folder's contents, the client has to be
+able to fill it in from the file tree; `python tests/test_fast_tree.py` fails with a diff if the locally
+built page no longer equals the server-rendered one.
diff --git a/README.md b/README.md
@@ -99,6 +99,7 @@ template.
| `VPN_ALLOWLIST` | unset | Comma-separated IPs exempt from VPN blocking. |
| `TRUST_CF_CONNECTING_IP` | `0` | Trust `CF-Connecting-IP` (set to `1` behind Cloudflare). |
| `TRUSTED_PROXY_HOPS` | `1` | Number of trusted reverse proxies in front of the app. |
+| `FAST_TREE_MAX_FILES` | `20000` | Above this many files, `/api/tree` is not shipped and instant folder navigation turns itself off. |
> **Self-hosting checklist:** the defaults above point at the original deployment. Before going live,
> set `HF_BUCKET_ID`, `CDN_BASE_URL`, `RAW_DOMAIN`/`RAW_BASE_URL`, `OIDC_AUDIENCE` and `GH_ALWAYS_ALLOW`
@@ -120,6 +121,8 @@ trust. The admin dashboard shows how the server currently sees your connection.
| `POST /api/put` | Upload files (`multipart/form-data`: `files`, `folder`). |
| `POST /api/put-url` | Fetch a remote URL into the CDN. |
| `GET /api/find?q=…` | Search all files. |
+| `GET /api/tree` | Every file as `[path, size]` (ETag + gzip) for instant folder navigation. |
+| `GET /api/shell` | Folder page skeleton + HTML fragments the browser fills in locally. |
| `GET /api/pack/{path}` | Zip a folder (`/api/pack-info/{path}` returns its size first). |
| `POST /api/shorten` | Create a short link. |
| `POST /api/gh-sync` | Sync a GitHub repo (GitHub Actions OIDC token + tar.gz archive). |
@@ -133,26 +136,49 @@ compressed archive. Video files are refused.
## Fast navigation
-Moving around is meant to feel close to a static site. Three things work together:
-
-1. **Link prefetching.** `app/static/prefetch.js` (loaded from `<head>` on the public pages, not the
- admin dashboard) fetches the pages linked from the current one as soon as the browser is idle.
-2. **Browser-cached, gzipped pages.** Rendered pages are identical for everyone, so they are sent with
- `Cache-Control: private, max-age=30` and gzip (`app/fast_html.py`). A prefetched page therefore sits
- in the browser's cache and the click is answered with no network at all; a page that wasn't
- prefetched in time still arrives about 5x smaller (a typical folder listing is ~60 KB of HTML, ~12 KB
- gzipped).
-3. **Fewer storage round trips.** Opening a folder used to cost a wasted `HEAD` request plus the
- listing. When the parent folder's listing is cached (it is, because that's the page you clicked
- from), the `HEAD` is skipped, and simultaneous requests for one folder share a single Hugging Face call.
-
-**Freshness trade-off:** because pages are cached for 30 s in the browser (on top of the server's 60 s
-listing cache), a file you just uploaded can take up to a minute or two to show in a folder you
-already had open. A hard refresh (Ctrl/Cmd+Shift+R) always fetches fresh.
+Moving around is meant to feel like a static site. Four things work together:
+
+1. **Instant folders.** The browser keeps the whole file tree (`/api/tree`, a few KB gzipped) and a page
+ "shell" (`/api/shell`) in localStorage. Clicking a folder link builds that folder's page locally and
+ hands it to [Turbo](https://turbo.hotwired.dev), so there is **no request at all**. It works on the very
+ first click after a page loads, even before anything has been prefetched, and for returning visitors
+ even before the network answers, because last visit's tree is already stored. (`app/static/fasttree.js`)
+2. **Link prefetching** for everything else (file pages, other pages). `app/static/prefetch.js` fetches
+ the pages linked from the current one as soon as the browser is idle.
+3. **Browser-cached, gzipped pages.** Rendered pages are identical for everyone, so they are sent with
+ `Cache-Control: private, max-age=30` and gzip (`app/fast_html.py`). A prefetched page is answered from
+ the browser's cache; one that wasn't arrives about 5x smaller (a typical folder listing is ~60 KB of
+ HTML, ~12 KB gzipped).
+4. **Fewer storage round trips.** Opening a folder used to cost a wasted `HEAD` request plus the listing.
+ When the parent folder's listing is cached (it is, because that's the page you clicked from) the `HEAD`
+ is skipped, and simultaneous requests for one folder share a single Hugging Face call.
+
+### How instant folders stay correct
+
+- **One copy of the markup.** The folder page's HTML lives in `app/templates/_listing.html` as Jinja
+ macros. `/api/shell` ships the server-rendered page skeleton plus each fragment with `@@placeholders@@`;
+ the browser only substitutes data (names, sizes, counts, the breadcrumb). There is no second copy of the
+ markup to drift. `tests/test_fast_tree.py` asserts that a locally built page equals the server's page for
+ a set of awkward folders (deep paths, `&` `'` `<` in names, emoji, rounding ties in sizes).
+- **Fresh.** The tree is revalidated in the background on page load, every 15 s while you browse, when the
+ tab regains focus, and straight after an upload (an unchanged tree is a tiny `304`). If the page on screen
+ turns out to have been built from an older tree, it is quietly rebuilt. The server clears its own caches on
+ every upload, delete and sync, so `/api/tree` is current right away.
+- **Falls back, never breaks.** No saved tree yet, a folder that isn't in the tree, a modified click
+ (Ctrl/Cmd/Shift/middle), a link with a query string or hash, `target`/`download` links, or any error all
+ just do the normal navigation, which is today's behaviour. File pages are not built locally.
+- **Bounded.** Buckets with more than `FAST_TREE_MAX_FILES` files (default 20,000) don't ship a tree and
+ the feature switches itself off. It stores roughly 100-200 KB in localStorage.
+- **Opt out** of it for a page with `<meta name="cdn-fast" content="off">`.
+
+**Freshness trade-off:** pages that *are* fetched from the server are cached for 30 s in the browser, so
+reopening one within 30 s can show a listing that is up to 30 s old. A hard refresh
+(Ctrl/Cmd+Shift+R) always fetches fresh.
### What gets prefetched
-The prefetcher is deliberately conservative:
+The prefetcher covers pages that instant folders can't build (file pages, other pages), and skips
+folder links the tree already handles. It is deliberately conservative:
- Same-origin links only, and never `/api/*`, `/admin`, `/mask/*`, `/pack/*`, `/static/*`, `/raw/*`,
downloads, `target`/`download` links, or anything inside `data-turbo="false"`.
@@ -173,6 +199,7 @@ header, and they don't show up in the Recent/Visited history, which is recorded
app/
main.py routes, uploads, zip packing, GitHub sync endpoint
fast_html.py browser-cache + gzip headers for rendered pages
+ fast_tree.py /api/tree and /api/shell payloads for instant folder navigation
storage.py Hugging Face bucket access, listing, caching, search
upload_guard.py upload checks: video blocking, VPN detection, client IP
admin.py /admin dashboard, bans, GitHub allowlist (encrypted state)
@@ -180,8 +207,8 @@ app/
shortener.py URL shortener
stats.py /stats
templates/ Jinja2 pages (index.html renders most page types)
- static/ icons, manifest, service worker, prefetch.js
-tests/ prefetcher unit tests (node) + offline server fast-path checks (python)
+ static/ icons, manifest, service worker, prefetch.js, fasttree.js
+tests/ JS unit/DOM/end-to-end tests (node) + offline server checks (python)
Dockerfile · render.yaml · start.sh
```
diff --git a/app/fast_tree.py b/app/fast_tree.py
@@ -0,0 +1,124 @@
+"""Data for instant folder navigation (static/fasttree.js).
+
+The browser keeps two things between visits:
+
+* the **tree**: every non-hidden file as ``[path, size]`` (``/api/tree``), small enough that the whole
+ bucket's structure fits in a few kilobytes gzipped, and
+* the **shell**: a server-rendered folder page with *slots* where the folder-specific parts go, plus
+ the HTML of each of those parts with ``@@placeholders@@`` (``/api/shell``).
+
+With both in hand a folder page can be assembled locally, with no request, and handed to Turbo. The
+markup comes from the same Jinja macros (templates/_listing.html) the server uses for ordinary
+renders, so the client never carries a second copy of it; only the data is filled in. See
+tests/test_fast_tree.py, which checks that a locally assembled page equals the server's.
+"""
+
+import gzip
+import hashlib
+import json
+import os
+
+from markupsafe import Markup
+from starlette.requests import Request
+from starlette.responses import Response
+
+# Above this many files the tree is not shipped and the browser falls back to normal navigation.
+MAX_FILES = int(os.getenv("FAST_TREE_MAX_FILES", "20000"))
+
+# Markers left in the shell where the folder-specific HTML goes. Each must appear exactly once.
+SLOTS = (
+ "<!--@root-header-->",
+ "<!--@breadcrumb-->",
+ "<!--@toolbar-->",
+ "<!--@list-->",
+ "<!--@stats-->",
+ "/*@visit*/",
+)
+
+_tree_memo: tuple | None = None # (items list, payload, etag): the items list is cached by storage, so identity is a cheap "unchanged" test
+_shell_memo: dict = {}
+
+
+def _digest(*chunks: str) -> str:
+ h = hashlib.sha1()
+ for c in chunks:
+ h.update(c.encode("utf-8"))
+ return h.hexdigest()[:16]
+
+
+def tree_payload(items: list) -> tuple[dict, str]:
+ """(payload, etag) for /api/tree, built from storage's full tree listing."""
+ global _tree_memo
+ if _tree_memo is not None and _tree_memo[0] is items:
+ return _tree_memo[1], _tree_memo[2]
+
+ files = sorted([it["path"], int(it["size"] or 0)] for it in items if not it["is_dir"])
+ etag = _digest(json.dumps(files, separators=(",", ":"), ensure_ascii=False))
+ if len(files) > MAX_FILES:
+ payload = {"v": etag, "n": len(files), "too_large": True}
+ else:
+ payload = {"v": etag, "n": len(files), "files": files}
+ _tree_memo = (items, payload, etag)
+ return payload, etag
+
+
+def shell_payload(env, ctx: dict) -> tuple[dict, str]:
+ """(payload, etag) for /api/shell: the skeleton page and the fill-in fragments."""
+ key = (ctx.get("cdn_base_url"), ctx.get("raw_base_url"))
+ if key in _shell_memo:
+ return _shell_memo[key]
+
+ html = env.get_template("index.html").render(
+ {**ctx, "page": "listing", "path": "", "items": [], "skeleton": True}
+ )
+ for slot in SLOTS:
+ if html.count(slot) != 1:
+ raise RuntimeError(f"shell slot {slot!r} should appear exactly once, found {html.count(slot)}")
+
+ m = env.get_template("_listing.html").module
+ parts = {
+ "root_header": m.root_header(),
+ "count": m.count("@@n@@", "@@word@@", "@@s@@"),
+ "toolbar": m.toolbar("@@counts@@", "@@path@@"),
+ "go_up": m.go_up("@@parent_path@@"),
+ "row_dir": m.row_dir("@@name@@", "@@path@@"),
+ "row_file": m.row_file("@@name@@", "@@path@@", "@@size_str@@"),
+ "empty": m.empty_state(),
+ "stats": m.footer_stats("@@n@@", "@@s@@", "@@size_str@@"),
+ "crumb_container": m.crumb_container("@@inner@@", ctx["cdn_base_url"], "@@tail@@"),
+ "crumb_link": m.crumb_link("@@href@@", "@@name@@"),
+ "crumb_collapsed": m.crumb_collapsed("@@menu@@"),
+ "copy_btn": m.copy_btn("@@path@@"),
+ "visit": m.visit_script(Markup(
+ 'type: "folder",\n path: @@path_json@@,\n cdn_url: @@cdn_url_json@@,'
+ )),
+ }
+ parts = {k: str(v) for k, v in parts.items()}
+ payload = {"html": html, "parts": parts, "cdn_base": ctx["cdn_base_url"]}
+ etag = _digest(html, json.dumps(parts, sort_keys=True), ctx["cdn_base_url"])
+ payload["v"] = etag
+ _shell_memo[key] = (payload, etag)
+ return payload, etag
+
+
+def _etag_matches(header: str, etag: str) -> bool:
+ # Proxies may turn a strong ETag into a weak one (W/"...") when they compress; accept either.
+ for token in header.split(","):
+ token = token.strip()
+ if token.startswith("W/"):
+ token = token[2:]
+ if token == f'"{etag}"':
+ return True
+ return False
+
+
+def json_response(request: Request, payload: dict, etag: str) -> Response:
+ """JSON with an ETag (so an unchanged tree costs a tiny 304) and gzip."""
+ headers = {"ETag": f'"{etag}"', "Cache-Control": "no-cache", "Vary": "Accept-Encoding"}
+ if _etag_matches(request.headers.get("if-none-match", ""), etag):
+ return Response(status_code=304, headers=headers)
+ body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
+ if "gzip" in request.headers.get("accept-encoding", "").lower():
+ body = gzip.compress(body, 6)
+ headers["Content-Encoding"] = "gzip"
+ return Response(body, media_type="application/json", headers=headers)
diff --git a/app/main.py b/app/main.py
@@ -18,10 +18,12 @@ from pydantic import BaseModel
from fastapi import FastAPI, Request, File, UploadFile, HTTPException, Form
from fastapi.responses import StreamingResponse, HTMLResponse, PlainTextResponse, RedirectResponse, FileResponse
from .fast_html import FastTemplates
+from .fast_tree import tree_payload, shell_payload, json_response
from .storage import (
is_file,
get_path_info,
+ _get_full_tree,
list_directory,
list_files_recursive,
upload_temp_file,
@@ -48,7 +50,7 @@ templates = FastTemplates(directory="app/templates")
STATIC_DIR = Path(__file__).parent / "static"
_NO_STORE_FILES = {"manifest.json", "sw.js"}
-_REVALIDATE_FILES = {"prefetch.js"}
+_REVALIDATE_FILES = {"prefetch.js", "fasttree.js"}
@app.get("/static/{filename}")
@@ -790,6 +792,31 @@ async def api_search(q: str = ""):
return {"results": results}
+@app.get("/api/tree")
+async def api_tree(request: Request):
+ """Every non-hidden file as [path, size], for instant folder navigation (static/fasttree.js)."""
+ items = await _get_full_tree()
+ if not items:
+ # Storage hiccup (or an empty bucket): say so rather than hand the browser an empty tree
+ # that would replace a good cached one.
+ raise HTTPException(status_code=503, detail="File tree unavailable")
+ payload, etag = tree_payload(items)
+ return json_response(request, payload, etag)
+
+
+@app.get("/api/shell")
+async def api_shell(request: Request):
+ """A folder page skeleton plus the HTML fragments the browser fills in locally."""
+ ctx = {
+ "cdn_base_url": CDN_BASE_URL,
+ "raw_base_url": RAW_BASE_URL,
+ "repo_file_count": None, # the footer stats slot is filled in from the tree
+ "repo_size_str": None,
+ }
+ payload, etag = shell_payload(templates.env, ctx)
+ return json_response(request, payload, etag)
+
+
@app.get("/pack/{batch_id}")
async def batch_page(request: Request, batch_id: str):
manifest = await get_batch_manifest(batch_id)
diff --git a/app/static/fasttree.js b/app/static/fasttree.js
@@ -0,0 +1,343 @@
+/*!
+ * cdnV2 instant folder navigation.
+ *
+ * The browser keeps the whole file tree (/api/tree) and a page "shell" (/api/shell) in localStorage.
+ * Clicking a link to a folder then builds that folder's page locally and hands it to Turbo, so there is
+ * no request at all: it works on the very first click after a page loads, even before any prefetch,
+ * which is what people who know where they're going do.
+ *
+ * All HTML comes from the server's own templates (templates/_listing.html); this file only fills in the
+ * data. tests/test_fast_tree.py checks that a locally built page equals the server-rendered one.
+ *
+ * Safe by construction: anything unusual (no tree yet, unknown folder, modified click, query/hash in the
+ * URL, any error) falls through to the normal request, so the worst case is today's behaviour. The tree
+ * is revalidated in the background (ETag, so an unchanged tree is a tiny 304); if it turns out the page
+ * you are on was built from an older tree, it is quietly rebuilt. Trees above FAST_TREE_MAX_FILES on the
+ * server are not shipped and the feature switches itself off.
+ *
+ * Opt out per page: <meta name="cdn-fast" content="off">.
+ */
+(function (global) {
+ 'use strict';
+
+ const STORE_KEY = 'cdn_fast_v1';
+ const MIN_REFRESH_MS = 15000;
+ const HIDDEN_ROW = '.gitattributes'; // rows the listing template skips
+ // First path segments that belong to the app, never to the CDN's content (same list as prefetch.js).
+ const RESERVED = new Set(['api', 'admin', 'mask', 'pack', 'static', 'raw']);
+
+ // --- text helpers: byte-for-byte what Jinja produces ---------------------------------------------
+
+ const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ // markupsafe.escape
+ function escapeHtml(v) {
+ return String(v).replace(/[&<>"']/g, (c) => ESC[c]);
+ }
+
+ // Jinja's |tojson: ASCII-only JSON, with <, >, & and ' as \u escapes (safe inside <script>).
+ function jsonForScript(v) {
+ return JSON.stringify(v)
+ .replace(/[^\x20-\x7e]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'))
+ .replace(/</g, '\\u003c')
+ .replace(/>/g, '\\u003e')
+ .replace(/&/g, '\\u0026')
+ .replace(/'/g, '\\u0027');
+ }
+
+ // storage.format_size. Python rounds half to even on the exact value, JavaScript's toFixed rounds
+ // half up, and 1.125 KB is exactly representable, so do the arithmetic exactly with BigInt.
+ function formatSize(bytes) {
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const n = BigInt(Math.trunc(bytes));
+ let idx = 0;
+ let d = 1n;
+ while (n >= d * 1024n && idx < units.length - 1) {
+ d *= 1024n;
+ idx++;
+ }
+ if (idx === 0) return `${n} ${units[0]}`;
+ const scaled = n * 100n;
+ let q = scaled / d;
+ const r2 = (scaled % d) * 2n;
+ if (r2 > d || (r2 === d && q % 2n === 1n)) q += 1n;
+ return `${q / 100n}.${(q % 100n).toString().padStart(2, '0')} ${units[idx]}`;
+ }
+
+ // --- the tree --------------------------------------------------------------------------------------
+
+ // files: [[path, size], ...] -> folder index
+ function buildIndex(files) {
+ const folders = new Map(); // folder path -> { dirs: Set(name), files: [[name, size]] }
+ const get = (p) => {
+ let f = folders.get(p);
+ if (!f) folders.set(p, (f = { dirs: new Set(), files: [] }));
+ return f;
+ };
+ get('');
+ let bytes = 0;
+ for (const [path, size] of files) {
+ const segs = path.split('/');
+ const name = segs.pop();
+ let parent = '';
+ for (const seg of segs) {
+ get(parent).dirs.add(seg);
+ parent = parent ? parent + '/' + seg : seg;
+ get(parent);
+ }
+ get(parent).files.push([name, size]);
+ bytes += size;
+ }
+ return { folders, count: files.length, bytes };
+ }
+
+ // Same items, same order as storage.list_directory: folders first, then by lower-cased name.
+ function listFolder(index, path) {
+ const f = index.folders.get(path);
+ if (!f) return null;
+ const prefix = path ? path + '/' : '';
+ const cmp = (a, b) => {
+ const x = a.name.toLowerCase();
+ const y = b.name.toLowerCase();
+ return x < y ? -1 : x > y ? 1 : 0;
+ };
+ const dirs = [...f.dirs].map((n) => ({ name: n, path: prefix + n, is_dir: true, size_str: '-' }));
+ const files = f.files.map(([n, s]) => ({ name: n, path: prefix + n, is_dir: false, size_str: formatSize(s) }));
+ return dirs.sort(cmp).concat(files.sort(cmp));
+ }
+
+ // --- building a page -------------------------------------------------------------------------------
+
+ // Replace @@key@@ in one pass, so a value that happens to contain "@@x@@" is never expanded again.
+ function fill(tpl, map) {
+ return tpl.replace(/@@([a-z_]+)@@/g, (m, k) => (Object.prototype.hasOwnProperty.call(map, k) ? map[k] : m));
+ }
+
+ function breadcrumb(path, parts) {
+ const crumbs = [{ name: 'root', href: '/' }];
+ let cur = '';
+ for (const seg of path.split('/')) {
+ cur += seg + '/';
+ crumbs.push({ name: seg, href: '/' + cur.slice(0, -1) });
+ }
+ const link = (c) => fill(parts.crumb_link, { href: escapeHtml(c.href), name: escapeHtml(c.name) });
+ const last = crumbs.length - 1;
+ let inner;
+ if (crumbs.length > 4) {
+ // root, "...", then the last two; the folders in between go into the "..." menu
+ const menu = ' ' + crumbs.slice(1, -2).map(link).join(' ') + ' ';
+ inner = [link(crumbs[0]), fill(parts.crumb_collapsed, { menu }), '/ ' + link(crumbs[last - 1]), '/ ' + link(crumbs[last])].join(' ');
+ } else {
+ inner = crumbs.map((c, i) => (i === 0 ? link(c) : '/ ' + link(c))).join(' ');
+ }
+ return fill(parts.crumb_container, { inner: ' ' + inner + ' ', tail: fill(parts.copy_btn, { path: escapeHtml(path) }) });
+ }
+
+ const SLOT_RE = /<!--@(?:root-header|breadcrumb|toolbar|list|stats)-->|\/\*@visit\*\//g;
+
+ // The complete HTML of the page for folder `path` ('' is the root), or null if the tree has no such folder.
+ function compose(shell, index, path) {
+ const items = listFolder(index, path);
+ if (!items) return null;
+ const p = shell.parts;
+ const visible = items.filter((i) => i.name !== HIDDEN_ROW);
+ const dirs = visible.filter((i) => i.is_dir).length;
+ const files = visible.length - dirs;
+ const segs = path ? path.split('/') : [];
+ const parent = segs.length > 1 ? '/' + segs.slice(0, -1).join('/') : '/';
+
+ const count = (n, word) => fill(p.count, { n: String(n), word, s: n === 1 ? '' : 's' });
+ let counts = '';
+ if (dirs > 0) counts += count(dirs, 'folder');
+ if (dirs > 0 && files > 0) counts += ' & ';
+ if (files > 0) counts += count(files, 'file');
+ if (dirs === 0 && files === 0) counts += '0 items';
+
+ const rows = visible.map((i) =>
+ i.is_dir
+ ? fill(p.row_dir, { name: escapeHtml(i.name), path: escapeHtml(i.path) })
+ : fill(p.row_file, { name: escapeHtml(i.name), path: escapeHtml(i.path), size_str: escapeHtml(i.size_str) })
+ );
+ let list = path ? fill(p.go_up, { parent_path: escapeHtml(parent) }) + '\n' : '';
+ list += rows.join('\n');
+ if (visible.length === 0) list += '\n' + p.empty;
+
+ const values = {
+ '<!--@root-header-->': path ? '' : p.root_header,
+ '<!--@breadcrumb-->': path ? breadcrumb(path, p) : '',
+ '<!--@toolbar-->': items.length ? fill(p.toolbar, { counts: ' ' + counts + ' ', path: escapeHtml(path) }) : '',
+ '<!--@list-->': list,
+ '<!--@stats-->': fill(p.stats, { n: String(index.count), s: index.count === 1 ? '' : 's', size_str: formatSize(index.bytes) }),
+ '/*@visit*/': path
+ ? fill(p.visit, { path_json: jsonForScript(path), cdn_url_json: jsonForScript(shell.cdn_base + '/' + path + '/') })
+ : '',
+ };
+ return shell.html.replace(SLOT_RE, (m) => values[m]);
+ }
+
+ // '/a%26b/x/' -> 'a&b/x'; null when the URL is not a plain content path.
+ function folderPathFromUrl(url) {
+ if (url.search || url.hash) return null;
+ let p;
+ try {
+ p = decodeURIComponent(url.pathname);
+ } catch (e) {
+ return null;
+ }
+ p = p.replace(/^\/+|\/+$/g, '');
+ if (RESERVED.has(p.split('/')[0])) return null;
+ return p;
+ }
+
+ // --- in the browser --------------------------------------------------------------------------------
+
+ function boot(win) {
+ const doc = win.document;
+ if (!doc || win.__cdnFast || typeof win.fetch !== 'function') return;
+
+ const state = { tree: null, shell: null, lastCheck: 0, disabled: false, pending: null, local: null };
+
+ function install(tree, shell) {
+ state.tree = { v: tree.v, files: tree.files, index: buildIndex(tree.files) };
+ state.shell = shell;
+ }
+ function persist() {
+ try {
+ win.localStorage.setItem(
+ STORE_KEY,
+ JSON.stringify({ tree: { v: state.tree.v, files: state.tree.files }, shell: state.shell })
+ );
+ } catch (e) { /* storage full or blocked: still works for this session */ }
+ }
+
+ // Warm start: what we saved on the last visit is usable immediately, before any network.
+ try {
+ const saved = JSON.parse(win.localStorage.getItem(STORE_KEY) || 'null');
+ if (saved && saved.tree && saved.tree.files && saved.shell && saved.shell.parts) install(saved.tree, saved.shell);
+ } catch (e) { /* corrupt entry: refetched below */ }
+
+ const turboReady = () => !!(win.Turbo && typeof win.Turbo.visit === 'function');
+ const optedOut = () => !!doc.querySelector('meta[name="cdn-fast"][content="off"]');
+ const ready = () => !state.disabled && !!state.tree && !!state.shell && turboReady() && !optedOut();
+
+ function pathFor(url) {
+ if (!ready() || url.origin !== win.location.origin) return null;
+ const path = folderPathFromUrl(url);
+ return path !== null && state.tree.index.folders.has(path) ? path : null;
+ }
+
+ function visit(url, path, action) {
+ const html = compose(state.shell, state.tree.index, path);
+ if (html === null) return false;
+ state.pending = { path, treeV: state.tree.v };
+ win.Turbo.visit(url.pathname, { action, response: { statusCode: 200, responseHTML: html } });
+ return true;
+ }
+
+ function onClick(e) {
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ const a = e.target && e.target.closest && e.target.closest('a[href]');
+ if (!a || !ready()) return;
+ if (a.hasAttribute('download') || (a.target && a.target !== '_self') || a.closest('[data-turbo="false"]')) return;
+ try {
+ const url = new URL(a.href, win.location.href);
+ const path = pathFor(url);
+ if (path === null) return;
+ // Turbo ignores a click that has been default-prevented, so it doesn't also fetch the page.
+ if (visit(url, path, 'advance')) e.preventDefault();
+ } catch (err) {
+ state.pending = null; // anything odd: let the normal navigation happen
+ }
+ }
+ doc.addEventListener('click', onClick, true);
+
+ function getJson(url) {
+ return win.fetch(url, { credentials: 'same-origin', priority: 'low' }).then((r) => {
+ if (!r.ok) throw new Error(url + ' ' + r.status);
+ return r.json();
+ });
+ }
+
+ // If the page on screen was built from an older tree than the one we now have, rebuild it.
+ function reconcile() {
+ const here = state.local;
+ if (!here || !state.tree || here.treeV === state.tree.v) return;
+ let url;
+ try {
+ url = new URL(win.location.href);
+ } catch (e) {
+ return;
+ }
+ if (folderPathFromUrl(url) !== here.path) return;
+ if (!visit(url, here.path, 'replace')) win.Turbo.visit(url.pathname, { action: 'replace' }); // folder is gone: ask the server
+ }
+
+ let inflight = null;
+ let again = false;
+ function refresh(force) {
+ const now = Date.now();
+ if (!force && now - state.lastCheck < MIN_REFRESH_MS) return Promise.resolve();
+ // One request at a time. A forced refresh that arrives mid-flight (e.g. several files finishing
+ // uploading together) runs once more afterwards, because the flight may have started too early.
+ if (inflight) {
+ if (force) again = true;
+ return inflight;
+ }
+ state.lastCheck = now;
+ inflight = Promise.all([getJson('/api/tree'), getJson('/api/shell')])
+ .then(([tree, shell]) => {
+ if (tree.too_large) {
+ state.disabled = true;
+ try { win.localStorage.removeItem(STORE_KEY); } catch (e) { /* ignore */ }
+ return;
+ }
+ if (state.tree && state.shell && state.tree.v === tree.v && state.shell.v === shell.v) return;
+ install(tree, shell);
+ persist();
+ if (turboReady()) reconcile();
+ })
+ .catch(() => { /* offline, or the server is busy: keep what we have */ })
+ .then(() => {
+ inflight = null;
+ if (again) {
+ again = false;
+ return refresh(true);
+ }
+ });
+ return inflight;
+ }
+
+ // Keep the tree fresh without ever blocking anything.
+ const idle = (fn) => (win.requestIdleCallback ? win.requestIdleCallback(fn, { timeout: 2000 }) : win.setTimeout(fn, 300));
+ const start = () => idle(() => refresh(false));
+ if (doc.readyState !== 'loading') start();
+ else doc.addEventListener('DOMContentLoaded', start, { once: true });
+
+ doc.addEventListener('turbo:before-visit', () => { if (!state.pending) state.local = null; });
+ doc.addEventListener('turbo:load', () => {
+ if (state.pending) {
+ state.local = state.pending;
+ state.pending = null;
+ }
+ idle(() => refresh(false));
+ });
+ doc.addEventListener('visibilitychange', () => { if (!doc.hidden) refresh(false); });
+
+ win.__cdnFast = {
+ refresh, // refresh(true) forces a check, e.g. right after an upload
+ handles: (url) => {
+ try {
+ return pathFor(new URL(url, win.location.href)) !== null;
+ } catch (e) {
+ return false;
+ }
+ },
+ stats: () => ({ ready: ready(), files: state.tree ? state.tree.index.count : 0, treeVersion: state.tree && state.tree.v, shellVersion: state.shell && state.shell.v }),
+ };
+ }
+
+ if (typeof module === 'object' && module.exports) {
+ module.exports = { escapeHtml, jsonForScript, formatSize, buildIndex, listFolder, compose, folderPathFromUrl, boot };
+ } else {
+ boot(global);
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/app/static/prefetch.js b/app/static/prefetch.js
@@ -187,6 +187,8 @@
base
);
if (!url || dedupe.has(url)) return;
+ // A folder the instant-navigation tree can build locally needs no server round trip at all.
+ if (win.__cdnFast && win.__cdnFast.handles(url)) return;
dedupe.add(url);
const r = a.getBoundingClientRect();
// Page content first (on-screen links, plus a screenful below, ahead of the rest), then site
diff --git a/app/templates/_listing.html b/app/templates/_listing.html
@@ -0,0 +1,143 @@
+{#-
+ Page fragments shared by the server-rendered pages and the client-side "instant folder" renderer
+ (static/fasttree.js). Each fragment is written once, here. The server calls these macros with real
+ values; /api/shell calls them with @@placeholders@@ that the browser fills in from the cached tree.
+ Change markup here and both stay in step (tests/test_fast_tree.py checks they do).
+-#}
+
+{% macro root_header() %}
+ <h1>A small file shelf for <a href="https://amit.is-a.dev">my</a> projects.</h1>
+ <blockquote class="site-notice">
+ Files here can now be synced straight from GitHub on every push — click
+ <a href="/gh-sync">Sync</a> at the top to see how to set it up. Project assets live here;
+ files and paths may change over time, so use direct links with that in mind.
+ </blockquote>
+{% endmacro %}
+
+{% macro count(n, word, s) %}<strong style="color: var(--text-main);">{{ n }}</strong> {{ word }}{{ s }}{% endmacro %}
+
+{% macro counts(dir_count, file_count) %}
+ {% if dir_count > 0 %}
+ {{ count(dir_count, 'folder', 's' if dir_count != 1 else '') }}
+ {% endif %}
+ {% if dir_count > 0 and file_count > 0 %} & {% endif %}
+ {% if file_count > 0 %}
+ {{ count(file_count, 'file', 's' if file_count != 1 else '') }}
+ {% endif %}
+ {% if dir_count == 0 and file_count == 0 %}
+ 0 items
+ {% endif %}
+{% endmacro %}
+
+{% macro toolbar(counts_html, path) %}
+ <div class="listing-toolbar">
+ <div style="color: var(--text-dim);">
+
+ {{ counts_html }}
+ </div>
+ <button id="download-zip-btn" class="btn" data-path="{{ path }}" title="Download everything in this folder as a .zip">
+ <svg id="zip-spinner" class="spin-anim" style="display: none; width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <line x1="12" y1="2" x2="12" y2="6"></line>
+ <line x1="12" y1="18" x2="12" y2="22"></line>
+ <line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line>
+ <line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line>
+ <line x1="2" y1="12" x2="6" y2="12"></line>
+ <line x1="18" y1="12" x2="22" y2="12"></line>
+ <line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line>
+ <line x1="16.24" y1="4.93" x2="19.07" y2="7.76"></line>
+ </svg>
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
+ <span id="zip-btn-text">Download all (.zip)</span>
+ </button>
+ </div>
+{% endmacro %}
+
+{% macro go_up(parent_path) %}
+ <div class="list-item" data-name="..">
+ <div class="item-name">
+ <svg class="folder-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
+ <a href="{{ parent_path }}" class="folder-link">Go up a directory</a>
+ </div>
+ </div>
+{% endmacro %}
+
+{% macro row_dir(name, path) %}
+ <div class="list-item" data-name="{{ name }}">
+ <div class="item-name">
+ <svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
+ <a href="/{{ path }}" class="folder-link" data-full-name="{{ name }}/">{{ name }}/</a>
+ </div>
+ <div class="item-meta">
+ </div>
+ </div>
+{% endmacro %}
+
+{% macro row_file(name, path, size_str) %}
+ <div class="list-item" data-name="{{ name }}">
+ <div class="item-name">
+ <svg class="file-icon-list" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
+ <a href="/{{ path }}" class="file-link" data-full-name="{{ name }}">{{ name }}</a>
+ </div>
+ <div class="item-meta">
+ <span class="size-label">{{ size_str }}</span>
+ <a href="/api/get/{{ path }}" class="download-icon" title="Download" data-turbo="false">
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>
+ </a>
+ </div>
+ </div>
+{% endmacro %}
+
+{% macro empty_state() %}
+ <div class="empty-state">
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
+ <span>This directory is empty. <a href="/new" style="text-decoration: underline;">Upload a file</a> to get started.</span>
+ </div>
+{% endmacro %}
+
+{% macro footer_stats(n, s, size_str) %}
+ <div title="Across the whole repo">
+ <strong style="color: var(--text-main);">{{ n }}</strong> file{{ s }} · {{ size_str }} total
+ </div>
+{% endmacro %}
+
+{% macro crumb_container(inner, cdn_base, tail) %}
+ <div class="breadcrumb-container" data-cdn-base="{{ cdn_base }}">
+ <div class="breadcrumb" id="breadcrumb">
+{{ inner }}
+{{ tail }}
+ </div>
+ </div>
+{% endmacro %}
+
+{% macro crumb_link(href, name) %}<a href="{{ href }}">{{ name }}</a>{% endmacro %}
+
+{% macro crumb_collapsed(menu) %}
+ <div class="breadcrumb-collapsed">
+ <button type="button" class="breadcrumb-ellipsis" onclick="event.stopPropagation(); toggleBreadcrumbMenu(this)" aria-label="Show hidden path segments">…</button>
+ <div class="breadcrumb-menu">
+ {{ menu }}
+ </div>
+ </div>
+{% endmacro %}
+
+{% macro copy_btn(path) %}
+ <button type="button" class="copy-path-btn" title="Copy path" onclick="copyBreadcrumbPath(this)" data-path="{{ path }}">
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
+ </button>
+{% endmacro %}
+
+{% macro visit_script(fields) %}
+ (function () {
+ try {
+ var KEY = 'cdn_visited_v1';
+ var MAX = 50;
+ var entry = {
+ {{ fields }}
+ ts: Date.now()
+ };
+ var entries = JSON.parse(localStorage.getItem(KEY) || '[]').filter(function (e) { return e.path !== entry.path; });
+ entries.unshift(entry);
+ localStorage.setItem(KEY, JSON.stringify(entries.slice(0, MAX)));
+ } catch (e) {}
+ })();
+{% endmacro %}
diff --git a/app/templates/_navbar.html b/app/templates/_navbar.html
@@ -1,3 +1,4 @@
+{% from "_listing.html" import crumb_container, crumb_link, crumb_collapsed, copy_btn %}
{% include "_styles.html" %}
<style>
/* Navbar + breadcrumb. Uses the shared --ui- tokens from _styles.html. */
@@ -83,9 +84,7 @@
<a href="/admin" class="nav-admin" title="Admin" aria-label="Admin"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Admin</span></a>
</nav>
</header>
- {% if not (page == 'listing' and not path) %}
- <div class="breadcrumb-container" data-cdn-base="{{ cdn_base_url }}">
- <div class="breadcrumb" id="breadcrumb">
+ {% if skeleton %}<!--@breadcrumb-->{% elif not (page == 'listing' and not path) %}
{% set current = namespace(val='') %}
{% set ns = namespace(items=[{'name': 'root', 'href': '/'}]) %}
{% if path %}
@@ -114,34 +113,27 @@
{% set ns.items = ns.items + [{'name': filename, 'href': none}] %}
{% endif %}
+ {% set crumbs %}
{% if ns.items|length > 4 %}
{% set hidden = ns.items[1:-2] %}
- <a href="{{ ns.items[0].href }}">{{ ns.items[0].name }}</a>
- <div class="breadcrumb-collapsed">
- <button type="button" class="breadcrumb-ellipsis" onclick="event.stopPropagation(); toggleBreadcrumbMenu(this)" aria-label="Show hidden path segments">…</button>
- <div class="breadcrumb-menu">
- {% for c in hidden %}
- <a href="{{ c.href }}">{{ c.name }}</a>
- {% endfor %}
- </div>
- </div>
+ {{ crumb_link(ns.items[0].href, ns.items[0].name) }}
+ {% set menu %}
+ {% for c in hidden %}
+ {{ crumb_link(c.href, c.name) }}
+ {% endfor %}
+ {% endset %}
+ {{ crumb_collapsed(menu) }}
{% for c in ns.items[-2:] %}
- / {% if c.href %}<a href="{{ c.href }}">{{ c.name }}</a>{% else %}{{ c.name }}{% endif %}
+ / {% if c.href %}{{ crumb_link(c.href, c.name) }}{% else %}{{ c.name }}{% endif %}
{% endfor %}
{% else %}
{% for c in ns.items %}
- {% if loop.first %}<a href="{{ c.href }}">{{ c.name }}</a>
- {% else %}/ {% if c.href %}<a href="{{ c.href }}">{{ c.name }}</a>{% else %}{{ c.name }}{% endif %}
+ {% if loop.first %}{{ crumb_link(c.href, c.name) }}
+ {% else %}/ {% if c.href %}{{ crumb_link(c.href, c.name) }}{% else %}{{ c.name }}{% endif %}
{% endif %}
{% endfor %}
{% endif %}
-
- {% if path or page == 'file' %}
- <button type="button" class="copy-path-btn" title="Copy path" onclick="copyBreadcrumbPath(this)" data-path="{{ path }}">
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
- </button>
- {% endif %}
- </div>
- </div>
+ {% endset %}
+ {{ crumb_container(crumbs, cdn_base_url, copy_btn(path) if (path or page == 'file') else '') }}
{% endif %}
</div>
diff --git a/app/templates/documentation.html b/app/templates/documentation.html
@@ -53,6 +53,7 @@
footer{margin-top:4rem;padding-top:1.5rem;border-top:1px solid var(--border);font-size:.85rem;color:var(--muted);}
</style>
<script src="/static/prefetch.js" defer></script>
+ <script src="/static/fasttree.js" defer></script>
</head>
<body>
{% include "_navbar.html" %}
diff --git a/app/templates/gh_sync_docs.html b/app/templates/gh_sync_docs.html
@@ -121,6 +121,7 @@
}
</style>
<script src="/static/prefetch.js" defer></script>
+ <script src="/static/fasttree.js" defer></script>
</head>
<body>
{% include "_navbar.html" %}
diff --git a/app/templates/index.html b/app/templates/index.html
@@ -1,3 +1,4 @@
+{% from "_listing.html" import root_header, count, counts, toolbar, go_up, row_dir, row_file, empty_state, footer_stats, visit_script %}
<!DOCTYPE html>
<html lang="en">
<head>
@@ -28,6 +29,7 @@
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script>
<script src="/static/prefetch.js" defer></script>
+ <script src="/static/fasttree.js" defer></script>
<style>
:root {
@@ -844,49 +846,21 @@
</div>
<div class="container">
- {% if page == 'listing' and not path %}
- <h1>A small file shelf for <a href="https://amit.is-a.dev">my</a> projects.</h1>
- <blockquote class="site-notice">
- Files here can now be synced straight from GitHub on every push — click
- <a href="/gh-sync">Sync</a> at the top to see how to set it up. Project assets live here;
- files and paths may change over time, so use direct links with that in mind.
- </blockquote>
+ {% if skeleton %}<!--@root-header-->{% elif page == 'listing' and not path %}
+ {{ root_header() }}
{% endif %}
{% if page == 'listing' %}
- {% if items %}
- <div class="listing-toolbar">
- <div style="color: var(--text-dim);">
- {% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %}
- {% set file_count = visible_items | rejectattr("is_dir") | list | length %}
- {% set dir_count = visible_items | selectattr("is_dir") | list | length %}
-
- {% if dir_count > 0 %}
- <strong style="color: var(--text-main);">{{ dir_count }}</strong> folder{{ 's' if dir_count != 1 else '' }}
- {% endif %}
- {% if dir_count > 0 and file_count > 0 %} & {% endif %}
- {% if file_count > 0 %}
- <strong style="color: var(--text-main);">{{ file_count }}</strong> file{{ 's' if file_count != 1 else '' }}
- {% endif %}
- {% if dir_count == 0 and file_count == 0 %}
- 0 items
- {% endif %}
- </div>
- <button id="download-zip-btn" class="btn" data-path="{{ path }}" title="Download everything in this folder as a .zip">
- <svg id="zip-spinner" class="spin-anim" style="display: none; width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <line x1="12" y1="2" x2="12" y2="6"></line>
- <line x1="12" y1="18" x2="12" y2="22"></line>
- <line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line>
- <line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line>
- <line x1="2" y1="12" x2="6" y2="12"></line>
- <line x1="18" y1="12" x2="22" y2="12"></line>
- <line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line>
- <line x1="16.24" y1="4.93" x2="19.07" y2="7.76"></line>
- </svg>
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
- <span id="zip-btn-text">Download all (.zip)</span>
- </button>
- </div>
+ {% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %}
+ {% set file_count = visible_items | rejectattr("is_dir") | list | length %}
+ {% set dir_count = visible_items | selectattr("is_dir") | list | length %}
+ {% set parent_path = '/' %}
+ {% if path %}
+ {% set path_parts = path.split('/') %}
+ {% if path_parts|length > 1 %}{% set parent_path = '/' + path_parts[:-1]|join('/') %}{% endif %}
+ {% endif %}
+ {% if skeleton %}<!--@toolbar-->{% elif items %}
+ {{ toolbar(counts(dir_count, file_count), path) }}
{% endif %}
<div class="list-container">
<div class="list-group">
@@ -898,50 +872,24 @@
</div>
<div id="file-list">
+ {% if skeleton %}<!--@list-->{% else %}
{% if path %}
- <div class="list-item" data-name="..">
- <div class="item-name">
- <svg class="folder-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
- {% set parent_path = '/' %}
- {% set path_parts = path.split('/') %}
- {% if path_parts|length > 1 %}
- {% set parent_path = '/' + path_parts[:-1]|join('/') %}
- {% endif %}
- <a href="{{ parent_path }}" class="folder-link">Go up a directory</a>
- </div>
- </div>
+ {{ go_up(parent_path) }}
{% endif %}
{% for item in items %}
{% if item.name != '.gitattributes' %}
- <div class="list-item" data-name="{{ item.name }}">
- <div class="item-name">
- {% if item.is_dir %}
- <svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
- <a href="/{{ item.path }}" class="folder-link" data-full-name="{{ item.name }}/">{{ item.name }}/</a>
- {% else %}
- <svg class="file-icon-list" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
- <a href="/{{ item.path }}" class="file-link" data-full-name="{{ item.name }}">{{ item.name }}</a>
- {% endif %}
- </div>
- <div class="item-meta">
- {% if not item.is_dir %}
- <span class="size-label">{{ item.size_str }}</span>
- <a href="/api/get/{{ item.path }}" class="download-icon" title="Download" data-turbo="false">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>
- </a>
- {% endif %}
- </div>
- </div>
+ {% if item.is_dir %}
+ {{ row_dir(item.name, item.path) }}
+ {% else %}
+ {{ row_file(item.name, item.path, item.size_str) }}
+ {% endif %}
{% endif %}
{% endfor %}
-
- {% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %}
+
{% if not visible_items %}
- <div class="empty-state">
- <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
- <span>This directory is empty. <a href="/new" style="text-decoration: underline;">Upload a file</a> to get started.</span>
- </div>
+ {{ empty_state() }}
+ {% endif %}
{% endif %}
</div>
</div>
@@ -1098,6 +1046,8 @@
});
localStorage.setItem(KEY, JSON.stringify(entries.slice(0, MAX)));
} catch (e) {}
+ // A new file exists now: refresh the cached file tree so folder navigation shows it.
+ if (window.__cdnFast) window.__cdnFast.refresh(true);
}
function selectedFolder() {
@@ -1581,10 +1531,8 @@
<div>
amit/cdn · built by <a href="https://github.com/notamitgamer">notamitgamer</a><br>nothing here is a permanent fixture.
</div>
- {% if repo_file_count is not none %}
- <div title="Across the whole repo">
- <strong style="color: var(--text-main);">{{ repo_file_count }}</strong> file{{ '' if repo_file_count == 1 else 's' }} · {{ repo_size_str }} total
- </div>
+ {% if skeleton %}<!--@stats-->{% elif repo_file_count is not none %}
+ {{ footer_stats(repo_file_count, '' if repo_file_count == 1 else 's', repo_size_str) }}
{% endif %}
</footer>
</div>
@@ -1894,12 +1842,8 @@
// --- Visited history: silently logs file/folder pages viewed on this browser,
// shown on /history. localStorage only - there's no account system, so this is
// per-browser, same limitation as ordinary browser history.
- {% if page in ('file', 'listing') and path %}
- (function () {
- try {
- var KEY = 'cdn_visited_v1';
- var MAX = 50;
- var entry = {
+ {% if skeleton %}/*@visit*/{% elif page in ('file', 'listing') and path %}
+ {% set visit_fields %}
type: {{ ('file' if page == 'file' else 'folder')|tojson }},
path: {{ path|tojson }},
{% if page == 'file' %}
@@ -1909,13 +1853,8 @@
{% else %}
cdn_url: {{ (cdn_base_url ~ '/' ~ path ~ '/')|tojson }},
{% endif %}
- ts: Date.now()
- };
- var entries = JSON.parse(localStorage.getItem(KEY) || '[]').filter(function (e) { return e.path !== entry.path; });
- entries.unshift(entry);
- localStorage.setItem(KEY, JSON.stringify(entries.slice(0, MAX)));
- } catch (e) {}
- })();
+ {% endset %}
+ {{ visit_script(visit_fields) }}
{% endif %}
</script>
</body>
diff --git a/app/templates/stats.html b/app/templates/stats.html
@@ -74,6 +74,7 @@
:root { --c0: #4c8dff; --c1: #3fb950; --c2: #d29922; --c3: #f85149; --c4: #a371f7; --c5: #39c5cf; --c6: #db61a2; --c7: #8b949e; }
</style>
<script src="/static/prefetch.js" defer></script>
+ <script src="/static/fasttree.js" defer></script>
</head>
<body>
{% include "_navbar.html" %}
diff --git a/app/templates/url_shortener.html b/app/templates/url_shortener.html
@@ -16,6 +16,7 @@
<meta name="color-scheme" content="light dark">
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script>
<script src="/static/prefetch.js" defer></script>
+ <script src="/static/fasttree.js" defer></script>
<style>
:root {
/* Legacy names mapped onto the shared --ui- tokens (_styles.html) */
diff --git a/tests/compose.js b/tests/compose.js
@@ -0,0 +1,13 @@
+// Test harness: build folder pages the way the browser does (app/static/fasttree.js), from the saved
+// /api/tree and /api/shell responses. Usage: node tests/compose.js tree.json shell.json path1 path2 ...
+// Prints {"<path>": "<html>"} as JSON (null for a folder the tree doesn't have).
+const fs = require('node:fs');
+const { buildIndex, compose } = require('../app/static/fasttree.js');
+
+const [treeFile, shellFile, ...paths] = process.argv.slice(2);
+const tree = JSON.parse(fs.readFileSync(treeFile, 'utf8'));
+const shell = JSON.parse(fs.readFileSync(shellFile, 'utf8'));
+const index = buildIndex(tree.files);
+const out = {};
+for (const p of paths) out[p] = compose(shell, index, p);
+process.stdout.write(JSON.stringify(out));
diff --git a/tests/fake_bucket.py b/tests/fake_bucket.py
@@ -0,0 +1,78 @@
+"""A tiny in-memory stand-in for the Hugging Face bucket, shared by the offline tests.
+
+`install(storage, main)` patches the storage layer so the real FastAPI app can be exercised with no
+network. Directories have no `size` attribute, like the real tree items.
+"""
+
+import datetime
+
+# path -> size in bytes. Includes awkward cases on purpose: HTML-special characters, spaces, a deep
+# path (long breadcrumb), root-level files, an excluded .gitattributes and hidden internal prefixes.
+FILES = {
+ "README.md": 1200,
+ ".gitattributes": 66,
+ "top-level.txt": 7,
+ "bsc/README.md": 3000,
+ "bsc/semester_1/notes.txt": 12,
+ "bsc/semester_1/deep/a.bin": 5,
+ "bsc/semester_1/deep/er/est/x.txt": 1048576,
+ "bsc/semester_1/Zeta.pdf": 2500000,
+ "bsc/semester_1/alpha.pdf": 999,
+ "a&b/it's here/<x> \"q\".txt": 42,
+ "a&b/plain.txt": 1023,
+ "uploads/9f3a1c2b-photo one.png": 2048,
+ "bsc/ünï 😀/a.txt": 9,
+ "bsc/semester_1/deep/er/est/more/x.txt": 3,
+ "solo/only.txt": 1152,
+ "solo2/a.txt": 1,
+ "_logs/hidden.log": 1,
+ "_batches/b1.json": 1,
+}
+
+
+class Dir:
+ def __init__(self, path):
+ self.path = path
+
+
+class File:
+ def __init__(self, path, size):
+ self.path, self.size = path, size
+ self.uploaded_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
+
+
+def _children(path):
+ path = path.strip("/")
+ prefix = path + "/" if path else ""
+ out = {}
+ for fp, size in FILES.items():
+ if not fp.startswith(prefix):
+ continue
+ rest = fp[len(prefix):]
+ if "/" in rest:
+ d = prefix + rest.split("/")[0]
+ out[d] = Dir(d)
+ else:
+ out[fp] = File(fp, size)
+ return list(out.values())
+
+
+def fetch_tree(path):
+ return _children(path)
+
+
+def fetch_tree_recursive(path=""):
+ path = path.strip("/")
+ prefix = path + "/" if path else ""
+ return [File(fp, s) for fp, s in FILES.items() if fp.startswith(prefix)]
+
+
+async def get_file_info(path):
+ return {"exists": path in FILES, "size": FILES.get(path), "content_type": None}
+
+
+def install(storage, main):
+ storage._fetch_tree = fetch_tree
+ storage._fetch_tree_recursive = fetch_tree_recursive
+ storage.get_file_info = get_file_info
+ storage._cache.clear()
diff --git a/tests/fasttree.dom.test.js b/tests/fasttree.dom.test.js
@@ -0,0 +1,193 @@
+// Browser-glue tests for app/static/fasttree.js, run in jsdom with a fake Turbo.
+// Needs jsdom (CI installs it; locally: `npm install --no-save --no-package-lock jsdom`).
+// Run with: node --test tests/*.test.js
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+let JSDOM, VirtualConsole;
+try {
+ ({ JSDOM, VirtualConsole } = require('jsdom'));
+} catch (e) {
+ test('fasttree DOM tests', { skip: 'jsdom is not installed' }, () => {});
+}
+
+if (JSDOM) {
+ const { boot } = require('../app/static/fasttree.js');
+
+ const parts = {
+ root_header: '[RH]', count: '<c>@@n@@ @@word@@@@s@@</c>', toolbar: '[TB:@@counts@@|@@path@@]', go_up: '[UP:@@parent_path@@]',
+ row_dir: '[D:@@name@@]', row_file: '[F:@@name@@]', empty: '[EMPTY]', stats: '[ST:@@n@@@@s@@|@@size_str@@]',
+ crumb_container: '[CC:@@inner@@|@@tail@@]', crumb_link: '<@@href@@:@@name@@>', crumb_collapsed: '[COL:@@menu@@]',
+ copy_btn: '[CP:@@path@@]', visit: 'V(@@path_json@@)',
+ };
+ const shellV = (v) => ({ v, cdn_base: 'https://cdn.example.com', parts,
+ html: 'PAGE<!--@root-header-->|<!--@breadcrumb-->|<!--@toolbar-->|<!--@list-->|<!--@stats-->|/*@visit*/' });
+ const treeV = (v, files) => ({ v, n: files.length, files });
+ const FILES = [['README.md', 10], ['bsc/a.pdf', 20], ['bsc/sem1/n.txt', 30], ['music/song.mp3', 40]];
+
+ // Build a page with some links, boot the script against it, and return handles for assertions.
+ function env({ stored, turbo = true, url = 'https://cdn.example.com/', fetchImpl, extraHead = '' } = {}) {
+ const dom = new JSDOM(`<!doctype html><html><head>${extraHead}</head><body>
+ <a id="folder" href="/bsc">bsc</a>
+ <a id="deep" href="/bsc/sem1/">sem1</a>
+ <a id="root" href="/">home</a>
+ <a id="file" href="/README.md">readme</a>
+ <a id="unknown" href="/nope">nope</a>
+ <a id="query" href="/bsc?x=1">q</a>
+ <a id="hash" href="/bsc#top">h</a>
+ <a id="api" href="/api/get/bsc">api</a>
+ <a id="admin" href="/admin">admin</a>
+ <a id="blank" href="/bsc" target="_blank">b</a>
+ <a id="dl" href="/bsc" download>d</a>
+ <div data-turbo="false"><a id="noturbo" href="/bsc">n</a></div>
+ <a id="offsite" href="https://example.org/bsc">o</a>
+ </body></html>`, { url, pretendToBeVisual: true, virtualConsole: new VirtualConsole() });
+ const win = dom.window;
+ if (stored !== undefined) win.localStorage.setItem('cdn_fast_v1', typeof stored === 'string' ? stored : JSON.stringify(stored));
+ const visits = [];
+ if (turbo) win.Turbo = { visit: (u, o) => visits.push({ url: u, ...o }) };
+ win.fetch = fetchImpl || (() => Promise.reject(new Error('offline')));
+ boot(win);
+ const click = (id, init = {}) => {
+ const ev = new win.MouseEvent('click', { bubbles: true, cancelable: true, button: 0, ...init });
+ win.document.getElementById(id).dispatchEvent(ev);
+ return ev;
+ };
+ return { win, visits, click, api: win.__cdnFast };
+ }
+ const saved = (tv = 't1', sv = 's1', files = FILES) => ({ tree: { v: tv, files }, shell: shellV(sv) });
+ const jsonResponse = (body) => Promise.resolve({ ok: true, json: () => Promise.resolve(body) });
+
+ test('warm start: a saved tree is usable immediately, with no network', () => {
+ const e = env({ stored: saved() });
+ assert.equal(e.api.stats().ready, true);
+ assert.equal(e.api.stats().files, 4);
+ });
+
+ test('clicking a folder link builds the page locally and hands it to Turbo', () => {
+ const e = env({ stored: saved() });
+ const ev = e.click('folder');
+ assert.equal(ev.defaultPrevented, true);
+ assert.equal(e.visits.length, 1);
+ const v = e.visits[0];
+ assert.equal(v.url, '/bsc');
+ assert.equal(v.action, 'advance');
+ assert.equal(v.response.statusCode, 200);
+ assert.ok(v.response.responseHTML.startsWith('PAGE|[CC: </:root> / </bsc:bsc> |[CP:bsc]]|'), v.response.responseHTML);
+ assert.ok(v.response.responseHTML.includes('[UP:/]'));
+ assert.ok(v.response.responseHTML.includes('[D:sem1]') && v.response.responseHTML.includes('[F:a.pdf]'));
+ });
+
+ test('trailing slashes and the root work', () => {
+ const e = env({ stored: saved() });
+ assert.equal(e.click('deep').defaultPrevented, true);
+ assert.equal(e.click('root').defaultPrevented, true);
+ assert.deepEqual(e.visits.map((v) => v.url), ['/bsc/sem1/', '/']);
+ assert.ok(e.visits[1].response.responseHTML.startsWith('PAGE[RH]|'));
+ });
+
+ test('everything else falls through to the normal navigation', () => {
+ const e = env({ stored: saved() });
+ for (const id of ['file', 'unknown', 'query', 'hash', 'api', 'admin', 'blank', 'dl', 'noturbo', 'offsite']) {
+ assert.equal(e.click(id).defaultPrevented, false, id);
+ }
+ for (const init of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
+ assert.equal(e.click('folder', init).defaultPrevented, false, JSON.stringify(init));
+ }
+ assert.equal(e.visits.length, 0);
+ });
+
+ test('without a saved tree, without Turbo, or when the page opts out: no interception', () => {
+ assert.equal(env({}).click('folder').defaultPrevented, false);
+ assert.equal(env({ stored: saved(), turbo: false }).click('folder').defaultPrevented, false);
+ assert.equal(env({ stored: saved(), extraHead: '<meta name="cdn-fast" content="off">' }).click('folder').defaultPrevented, false);
+ });
+
+ test('a corrupt or partial saved entry is ignored, not fatal', () => {
+ for (const bad of ['{not json', '{}', JSON.stringify({ tree: { v: 'x' }, shell: {} }), 'null']) {
+ const e = env({ stored: bad });
+ assert.equal(e.api.stats().ready, false, bad);
+ assert.equal(e.click('folder').defaultPrevented, false);
+ }
+ });
+
+ test('an error while building falls back to the normal navigation', () => {
+ const e = env({ stored: { tree: { v: 't', files: FILES }, shell: { v: 's', cdn_base: 'x', html: 'x', parts: { ...parts, row_dir: null } } } });
+ assert.equal(e.click('folder').defaultPrevented, false);
+ assert.equal(e.visits.length, 0);
+ });
+
+ test('handles(): what the prefetcher should leave alone', () => {
+ const e = env({ stored: saved() });
+ assert.equal(e.api.handles('/bsc'), true);
+ assert.equal(e.api.handles('/README.md'), false);
+ assert.equal(e.api.handles('/api/get/x'), false);
+ assert.equal(e.api.handles('https://example.org/bsc'), false);
+ assert.equal(env({}).api.handles('/bsc'), false);
+ });
+
+ test('refresh fetches the tree and shell, saves them, and enables local navigation', async () => {
+ const calls = [];
+ const e = env({ fetchImpl: (u) => { calls.push(u); return jsonResponse(u === '/api/tree' ? treeV('t9', FILES) : shellV('s9')); } });
+ assert.equal(e.click('folder').defaultPrevented, false); // nothing yet
+ await e.api.refresh(true);
+ assert.deepEqual(calls.sort(), ['/api/shell', '/api/tree']);
+ assert.equal(e.api.stats().ready, true);
+ assert.equal(JSON.parse(e.win.localStorage.getItem('cdn_fast_v1')).tree.v, 't9');
+ assert.equal(e.click('folder').defaultPrevented, true);
+ });
+
+ test('forced refreshes arriving together share one request, plus one more afterwards', async () => {
+ let n = 0;
+ const e = env({ stored: saved(), fetchImpl: (u) => { n++; return jsonResponse(u === '/api/tree' ? treeV('t1', FILES) : shellV('s1')); } });
+ await Promise.all([e.api.refresh(true), e.api.refresh(true), e.api.refresh(true)]);
+ assert.equal(n, 4, 'two requests for the flight, two more for the single follow-up');
+ });
+
+ test('refresh is throttled unless forced, and an offline refresh keeps what it has', async () => {
+ let n = 0;
+ const e = env({ stored: saved(), fetchImpl: () => { n++; return Promise.reject(new Error('offline')); } });
+ await e.api.refresh(true);
+ const afterFirst = n;
+ await e.api.refresh(false);
+ assert.equal(n, afterFirst, 'throttled');
+ assert.equal(e.api.stats().ready, true);
+ assert.equal(e.click('folder').defaultPrevented, true);
+ });
+
+ test('a changed tree rebuilds the page on screen, but only if that page was built locally', async () => {
+ const newer = (u) => jsonResponse(u === '/api/tree' ? treeV('t2', [...FILES, ['bsc/new.pdf', 5]]) : shellV('s1'));
+ // 1) navigate locally to /bsc, then a newer tree arrives -> replace visit with the new content
+ const e = env({ stored: saved(), fetchImpl: newer });
+ e.click('folder');
+ e.win.history.pushState({}, '', '/bsc'); // what Turbo would do
+ e.win.document.dispatchEvent(new e.win.Event('turbo:load')); // marks the page as locally built
+ await e.api.refresh(true);
+ const last = e.visits[e.visits.length - 1];
+ assert.equal(last.action, 'replace');
+ assert.ok(last.response.responseHTML.includes('[F:new.pdf]'));
+ // 2) a server-rendered page is never rebuilt behind the user's back
+ const e2 = env({ stored: saved(), url: 'https://cdn.example.com/bsc', fetchImpl: newer });
+ await e2.api.refresh(true);
+ assert.equal(e2.visits.length, 0);
+ });
+
+ test('a folder deleted in the newer tree is re-requested from the server', async () => {
+ const gone = (u) => jsonResponse(u === '/api/tree' ? treeV('t3', [['README.md', 1]]) : shellV('s1'));
+ const e = env({ stored: saved(), fetchImpl: gone });
+ e.click('folder');
+ e.win.history.pushState({}, '', '/bsc');
+ e.win.document.dispatchEvent(new e.win.Event('turbo:load'));
+ await e.api.refresh(true);
+ const last = e.visits[e.visits.length - 1];
+ assert.deepEqual([last.url, last.action, last.response], ['/bsc', 'replace', undefined]);
+ });
+
+ test('a too-large tree switches the feature off and clears what was saved', async () => {
+ const e = env({ stored: saved(), fetchImpl: (u) => jsonResponse(u === '/api/tree' ? { v: 'big', n: 99999, too_large: true } : shellV('s1')) });
+ await e.api.refresh(true);
+ assert.equal(e.api.stats().ready, false);
+ assert.equal(e.win.localStorage.getItem('cdn_fast_v1'), null);
+ assert.equal(e.click('folder').defaultPrevented, false);
+ });
+}
diff --git a/tests/fasttree.test.js b/tests/fasttree.test.js
@@ -0,0 +1,99 @@
+// Run with: node --test tests/*.test.js
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { escapeHtml, jsonForScript, formatSize, buildIndex, listFolder, compose, folderPathFromUrl } =
+ require('../app/static/fasttree.js');
+
+const FILES = [
+ ['README.md', 1200], ['.gitattributes', 66],
+ ['bsc/zeta.pdf', 2500000], ['bsc/Alpha.pdf', 999], ['bsc/sem1/n.txt', 12], ['bsc/sem1/deep/a.bin', 5],
+ ['$&/<b>.txt', 7], ['@@name@@/x.txt', 1152],
+];
+const index = buildIndex(FILES);
+
+test('buildIndex: folders, files, totals', () => {
+ assert.deepEqual([...index.folders.keys()].sort(), ['', '$&', '@@name@@', 'bsc', 'bsc/sem1', 'bsc/sem1/deep']);
+ assert.equal(index.count, 8);
+ assert.equal(index.bytes, 1200 + 66 + 2500000 + 999 + 12 + 5 + 7 + 1152);
+});
+
+test('listFolder: folders first, then case-insensitive by name, with sizes', () => {
+ const items = listFolder(index, 'bsc');
+ assert.deepEqual(items.map((i) => [i.name, i.is_dir, i.size_str]), [
+ ['sem1', true, '-'], ['Alpha.pdf', false, '999 B'], ['zeta.pdf', false, '2.38 MB'],
+ ]);
+ assert.equal(items[1].path, 'bsc/Alpha.pdf');
+ assert.equal(listFolder(index, 'nope'), null);
+});
+
+test('formatSize: matches Python, including round-half-even ties', () => {
+ const cases = { 0: '0 B', 1023: '1023 B', 1024: '1.00 KB', 1152: '1.12 KB', 1408: '1.38 KB', 1048576: '1.00 MB', 2500000: '2.38 MB' };
+ for (const [n, want] of Object.entries(cases)) assert.equal(formatSize(Number(n)), want, n);
+});
+
+test('escapeHtml / jsonForScript', () => {
+ assert.equal(escapeHtml(`<a href="x">'&'</a>`), '<a href="x">'&'</a>');
+ assert.equal(jsonForScript('a</script>&\'é'), '"a\\u003c/script\\u003e\\u0026\\u0027\\u00e9"');
+});
+
+test('folderPathFromUrl: plain content paths only', () => {
+ const p = (s) => folderPathFromUrl(new URL(s, 'https://cdn.example.com'));
+ assert.equal(p('/'), '');
+ assert.equal(p('/bsc/'), 'bsc');
+ assert.equal(p('/a%26b/it%27s%20here'), "a&b/it's here");
+ assert.equal(p('/bsc?x=1'), null);
+ assert.equal(p('/bsc#top'), null);
+ for (const r of ['/api/get/x', '/admin', '/mask/abc', '/pack/b1', '/static/sw.js', '/raw/x']) assert.equal(p(r), null, r);
+ assert.equal(p('/administrator'), 'administrator'); // only whole segments are reserved
+ assert.equal(p('/%E0%A4%A'), null); // malformed escape
+});
+
+// A hand-made shell: every fragment is visible in the output, so substitution is easy to assert on.
+const shell = {
+ cdn_base: 'https://cdn.example.com',
+ html: 'A<!--@root-header-->B<!--@breadcrumb-->C<!--@toolbar-->D<!--@list-->E<!--@stats-->F/*@visit*/G',
+ parts: {
+ root_header: '[RH]', count: '<c>@@n@@ @@word@@@@s@@</c>', toolbar: '[TB:@@counts@@|@@path@@]',
+ go_up: '[UP:@@parent_path@@]', row_dir: '[D:@@name@@|@@path@@]', row_file: '[F:@@name@@|@@path@@|@@size_str@@]',
+ empty: '[EMPTY]', stats: '[ST:@@n@@@@s@@|@@size_str@@]', crumb_container: '[CC:@@inner@@|@@tail@@]',
+ crumb_link: '<@@href@@:@@name@@>', crumb_collapsed: '[COL:@@menu@@]', copy_btn: '[CP:@@path@@]',
+ visit: 'V(@@path_json@@,@@cdn_url_json@@)',
+ },
+};
+const norm = (s) => s.replace(/\s+/g, ' ');
+
+test('compose: root has the header, no breadcrumb/go-up/visit; hidden .gitattributes is not a row or a count', () => {
+ const html = norm(compose(shell, index, ''));
+ assert.match(html, /^A\[RH\]BC\[TB: <c>3 folders<\/c> & <c>1 file<\/c> \|\]D/);
+ assert.ok(!html.includes('.gitattributes'));
+ assert.ok(html.includes('[F:README.md|README.md|1.17 KB]'));
+ assert.ok(!html.includes('[UP:'));
+ assert.ok(html.endsWith('E[ST:8s|2.39 MB]FG'), html.slice(-40));
+});
+
+test('compose: a folder gets breadcrumb, go-up row, counts and a visit entry', () => {
+ const html = norm(compose(shell, index, 'bsc/sem1'));
+ assert.ok(html.includes('[CC: </:root> / </bsc:bsc> / </bsc/sem1:sem1> |[CP:bsc/sem1]]'), html);
+ assert.ok(html.includes('[UP:/bsc]'));
+ assert.ok(html.includes('<c>1 folder</c> & <c>1 file</c>'));
+ assert.ok(html.includes('V("bsc/sem1","https://cdn.example.com/bsc/sem1/")'));
+ assert.ok(!html.includes('[RH]'));
+});
+
+test('compose: top-level folders go up to "/"', () => {
+ assert.ok(norm(compose(shell, index, 'bsc')).includes('[UP:/]'));
+});
+
+test('compose: names are substituted literally (no $-patterns, no re-expansion of @@x@@)', () => {
+ const html = norm(compose(shell, index, '$&'));
+ assert.ok(html.includes('[F:<b>.txt|$&/<b>.txt|7 B]'), html);
+ const html2 = norm(compose(shell, index, '@@name@@'));
+ assert.ok(html2.includes('[F:x.txt|@@name@@/x.txt|1.12 KB]'), html2);
+});
+
+test('compose: empty-looking folder shows the empty state and "0 items"', () => {
+ const only = buildIndex([['solo/.gitattributes', 1]]);
+ const html = norm(compose(shell, only, 'solo'));
+ assert.ok(html.includes('0 items'));
+ assert.ok(html.includes('[EMPTY]'));
+});
diff --git a/tests/test_fast_tree.py b/tests/test_fast_tree.py
@@ -0,0 +1,172 @@
+"""Offline checks for instant folder navigation (app/fast_tree.py + app/static/fasttree.js).
+
+Run from the repo root: HF_BUCKET_ID=ci/x python tests/test_fast_tree.py (needs `node`)
+
+The important one: a folder page assembled in the browser from /api/tree + /api/shell must be the same
+HTML the server renders for that folder. If someone edits the listing markup in a way the client
+can't reproduce, this fails with a diff pointing at the first difference.
+"""
+
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import urllib.parse
+
+os.environ.setdefault("HF_BUCKET_ID", "ci/placeholder")
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, ROOT)
+sys.path.insert(0, os.path.join(ROOT, "tests"))
+
+from fastapi.testclient import TestClient # noqa: E402
+
+import app.main as m # noqa: E402
+from app import fast_tree, storage # noqa: E402
+import fake_bucket # noqa: E402
+
+fake_bucket.install(storage, m)
+client = TestClient(m.app)
+
+
+def check(cond, msg):
+ if not cond:
+ raise AssertionError(msg)
+ print("ok -", msg)
+
+
+def node(*args):
+ out = subprocess.run(["node", *args], capture_output=True, text=True, cwd=ROOT)
+ if out.returncode != 0:
+ raise AssertionError(f"node failed: {out.stderr}")
+ return out.stdout
+
+
+def norm(html):
+ return re.sub(r"\s+", " ", html).strip()
+
+
+# --- /api/tree ---------------------------------------------------------------------------------------
+
+gz = {"Accept-Encoding": "gzip"}
+r = client.get("/api/tree", headers=gz)
+tree = r.json()
+paths = {p for p, _ in tree["files"]}
+check(r.status_code == 200 and r.headers.get("content-encoding") == "gzip", "/api/tree is served gzipped")
+check(tree["n"] == len(tree["files"]) == len(paths), "...with one entry per file")
+check("README.md" in paths and ".gitattributes" in paths and "bsc/semester_1/notes.txt" in paths, "...containing the files")
+check(not any(p.startswith(("_logs", "_batches")) for p in paths), "...and none of the hidden internal prefixes")
+check(r.headers["etag"] == f'"{tree["v"]}"' and r.headers["cache-control"] == "no-cache", "...with an ETag that is revalidated each time")
+
+r2 = client.get("/api/tree", headers={"If-None-Match": r.headers["etag"]})
+check(r2.status_code == 304 and not r2.content, "an unchanged tree costs an empty 304")
+check(client.get("/api/tree", headers={"If-None-Match": "W/" + r.headers["etag"]}).status_code == 304, "...even when a proxy weakened the ETag")
+check(client.get("/api/tree", headers={"If-None-Match": '"stale"'}).status_code == 200, "a different ETag gets the full tree")
+
+fake_bucket.FILES["brand/new.txt"] = 5
+storage._cache.clear()
+check(client.get("/api/tree").json()["v"] != tree["v"], "a new file changes the tree's version")
+del fake_bucket.FILES["brand/new.txt"]
+storage._cache.clear()
+
+fast_tree.MAX_FILES, saved_max = 3, fast_tree.MAX_FILES
+fast_tree._tree_memo = None
+big = client.get("/api/tree").json()
+check(big.get("too_large") is True and "files" not in big, "a tree over FAST_TREE_MAX_FILES is not shipped")
+fast_tree.MAX_FILES = saved_max
+fast_tree._tree_memo = None
+
+saved_files = dict(fake_bucket.FILES)
+fake_bucket.FILES.clear()
+storage._cache.clear()
+check(client.get("/api/tree").status_code == 503, "an empty/failed listing is a 503, never an empty tree")
+fake_bucket.FILES.update(saved_files)
+storage._cache.clear()
+
+# --- /api/shell --------------------------------------------------------------------------------------
+
+r = client.get("/api/shell", headers=gz)
+shell = r.json()
+check(r.status_code == 200 and r.headers.get("content-encoding") == "gzip", "/api/shell is served gzipped")
+check(all(shell["html"].count(s) == 1 for s in fast_tree.SLOTS), "every slot appears exactly once in the shell")
+check("skeleton" not in shell["html"] and "{{" not in shell["html"], "the shell is fully rendered HTML")
+check(shell["cdn_base"] == m.CDN_BASE_URL, "...and carries the CDN base URL for the visited-history entry")
+check(client.get("/api/shell", headers={"If-None-Match": r.headers["etag"]}).status_code == 304, "the shell revalidates with a 304 too")
+check(client.get("/static/fasttree.js").status_code == 200, "/static/fasttree.js is served")
+
+# --- the page built in the browser == the page the server renders ------------------------------------
+
+tmp = tempfile.mkdtemp()
+tree = client.get("/api/tree").json()
+json.dump(tree, open(f"{tmp}/tree.json", "w"))
+json.dump(shell, open(f"{tmp}/shell.json", "w"))
+
+folders = [
+ "", # root: header, no breadcrumb, no "go up"
+ "bsc", "bsc/semester_1",
+ "bsc/semester_1/deep/er/est", # 6 crumbs: collapsed breadcrumb
+ "bsc/semester_1/deep/er/est/more",
+ "a&b", "a&b/it's here", # HTML-special characters in folder names
+ "uploads", # a file name with a space
+ "bsc/ünï 😀", # non-ASCII + astral characters (escaping in the history script)
+ "solo", # 1152 B: exact tie, Python rounds half to even ("1.12 KB")
+ "solo2", # singular counts: "1 file"
+]
+built = json.loads(node("tests/compose.js", f"{tmp}/tree.json", f"{tmp}/shell.json", *folders))
+for folder in folders:
+ url = "/" + urllib.parse.quote(folder, safe="/")
+ server = client.get(url, headers={"Accept-Encoding": "identity"})
+ assert server.status_code == 200, (url, server.status_code)
+ a, b = norm(server.text), norm(built[folder] or "")
+ if a != b:
+ i = next((k for k in range(min(len(a), len(b))) if a[k] != b[k]), min(len(a), len(b)))
+ raise AssertionError(
+ f"locally built page differs from the server's for {folder!r} at char {i}\n"
+ f" server: ...{a[max(0, i - 120):i + 160]}\n client: ...{b[max(0, i - 120):i + 160]}"
+ )
+ print(f"ok - folder {folder or '(root)'!r}: built page == server page")
+
+missing = json.loads(node("tests/compose.js", f"{tmp}/tree.json", f"{tmp}/shell.json", "no/such/folder"))
+check(missing["no/such/folder"] is None, "a folder that isn't in the tree is not built locally (the server decides)")
+
+# --- text helpers match Python/Jinja byte for byte ---------------------------------------------------
+
+sizes = [0, 1, 1023, 1024, 1025, 1152, 1536, 2047, 3000, 999999, 1048575, 1048576, 2500000, 10**9,
+ 5 * 2**30 + 12345, 2**40, 3 * 2**40 + 7, 2**50, 1000 * 2**40]
+js = json.loads(node("-e", "const {formatSize}=require('./app/static/fasttree.js');"
+ "console.log(JSON.stringify(JSON.parse(process.argv[1]).map(formatSize)))", json.dumps(sizes)))
+py = [storage.format_size(s) for s in sizes]
+check(js == py, f"format_size agrees with Python for {len(sizes)} sizes, including exact rounding ties")
+
+tricky = ["plain", "a&b", "it's", "<script>alert(1)</script>", "ünï 😀", "tab\there", 'q"uote', "back\\slash",
+ "\x7f", "\u2028", "line\nbreak", "$&", "@@name@@", "</script>"]
+render_tojson = m.templates.env.from_string("{{ x|tojson }}").render # the real filter, as the templates use it
+js = json.loads(node("-e", "const {jsonForScript}=require('./app/static/fasttree.js');"
+ "console.log(JSON.stringify(JSON.parse(process.argv[1]).map(jsonForScript)))", json.dumps(tricky)))
+check(js == [render_tojson(x=t) for t in tricky], "jsonForScript agrees with Jinja's |tojson")
+
+from markupsafe import escape # noqa: E402
+js = json.loads(node("-e", "const {escapeHtml}=require('./app/static/fasttree.js');"
+ "console.log(JSON.stringify(JSON.parse(process.argv[1]).map(escapeHtml)))", json.dumps(tricky)))
+check(js == [str(escape(t)) for t in tricky], "escapeHtml agrees with markupsafe.escape")
+
+# --- real Turbo + real pages, in jsdom ---------------------------------------------------------------
+
+e2e_dir = tempfile.mkdtemp()
+json.dump(tree, open(f"{e2e_dir}/tree.json", "w"))
+json.dump(shell, open(f"{e2e_dir}/shell.json", "w"))
+for name, url in {"from": "/bsc", "to": "/bsc/semester_1", "root": "/"}.items():
+ open(f"{e2e_dir}/{name}.html", "w", encoding="utf-8").write(client.get(url, headers={"Accept-Encoding": "identity"}).text)
+
+have_deps = all(os.path.isdir(os.path.join(ROOT, "node_modules", d)) for d in ("jsdom", "@hotwired/turbo"))
+if have_deps:
+ out = subprocess.run(["node", "tests/turbo_e2e.js", e2e_dir], capture_output=True, text=True, cwd=ROOT)
+ print(out.stdout.rstrip())
+ check(out.returncode == 0, "end-to-end: real Turbo renders the locally built page, history and Back work, no page request")
+elif os.getenv("REQUIRE_E2E"):
+ raise AssertionError("jsdom / @hotwired/turbo are not installed but REQUIRE_E2E is set")
+else:
+ print("skip - end-to-end test (npm install --no-save --no-package-lock jsdom @hotwired/turbo@8.0.4 to enable)")
+
+print("\nall fast-tree checks passed")
diff --git a/tests/turbo_e2e.js b/tests/turbo_e2e.js
@@ -0,0 +1,86 @@
+// End-to-end check of instant folder navigation with the REAL Turbo and the REAL server-rendered pages.
+// Not run directly: tests/test_fast_tree.py writes fixtures to a directory and calls
+// node tests/turbo_e2e.js <fixtures-dir>
+// Needs jsdom and @hotwired/turbo@8.0.4 (CI installs both; locally: npm i --no-save --no-package-lock jsdom @hotwired/turbo@8.0.4).
+const fs = require('node:fs');
+const path = require('node:path');
+const { JSDOM, VirtualConsole } = require('jsdom');
+const { boot } = require('../app/static/fasttree.js');
+
+const dir = process.argv[2];
+const read = (f) => fs.readFileSync(path.join(dir, f), 'utf8');
+const tree = JSON.parse(read('tree.json'));
+const shell = JSON.parse(read('shell.json'));
+const turboSrc = fs.readFileSync(require.resolve('@hotwired/turbo/dist/turbo.es2017-umd.js'), 'utf8');
+
+const failures = [];
+const check = (cond, msg) => { if (!cond) failures.push(msg); console.log((cond ? 'ok - ' : 'FAIL - ') + msg); };
+const norm = (s) => s.replace(/\s+/g, ' ').trim();
+// The parts of a listing page that depend on the folder. (Whole-page equality is checked elsewhere.)
+const region = (doc) => ['.breadcrumb-container', '.listing-toolbar', '#file-list', 'footer']
+ .map((sel) => { const el = doc.querySelector(sel); return norm(el ? el.outerHTML : '(none)'); }).join('\n');
+const waitFor = async (cond, ms = 4000) => { const end = Date.now() + ms; while (Date.now() < end) { if (cond()) return true; await new Promise((r) => setTimeout(r, 10)); } return cond(); };
+const parse = (html) => new JSDOM(html).window.document;
+
+(async () => {
+ // The page the user is on: the server's own rendering of /bsc (external scripts are not loaded in jsdom).
+ const stripExternal = (h) => h.replace(/<script[^>]*\bsrc="[^"]*"[^>]*><\/script>/g, '');
+ const dom = new JSDOM(stripExternal(read('from.html')), {
+ url: 'https://cdn.example.com/bsc', runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: new VirtualConsole(),
+ });
+ const win = dom.window;
+ win.eval(turboSrc); // the real Turbo (what the CDN <script type=module> provides)
+ check(typeof win.Turbo.visit === 'function', 'real Turbo 8.0.4 is loaded');
+ win.localStorage.setItem('cdn_fast_v1', JSON.stringify({ tree: { v: tree.v, files: tree.files }, shell }));
+ let networkCalls = 0;
+ const requested = [];
+ win.fetch = (u, o) => {
+ const url = String(u && u.url || u);
+ // the tree/shell revalidating in the background is expected; a page request is not
+ if (url !== '/api/tree' && url !== '/api/shell') { networkCalls++; requested.push(`${url} (during step ${step})`); } return Promise.reject(new Error('the test forbids network requests')); };
+ let step = 'start';
+ const origPush = win.history.pushState.bind(win.history);
+ const pushed = [];
+ win.history.pushState = (...a) => { pushed.push(a[2]); return origPush(...a); };
+ boot(win);
+
+ step = 'click';
+ const link = win.document.querySelector('#file-list a[href="/bsc/semester_1"]');
+ check(!!link, 'the page has a link to the sub-folder');
+ await waitFor(() => win.document.readyState === 'complete');
+ await new Promise((r) => setTimeout(r, 100)); // let Turbo's own start-up settle
+ link.dispatchEvent(new win.MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }));
+ await waitFor(() => win.location.pathname === '/bsc/semester_1' && win.document.querySelector('a[href="/bsc/semester_1/deep"]'));
+
+ check(win.location.pathname === '/bsc/semester_1', `the address changed to /bsc/semester_1 (is ${win.location.pathname})`);
+ check(pushed.length === 1, 'Turbo added a history entry');
+ check(networkCalls === 0, 'no page was requested from the server for the navigation');
+ const got = region(win.document);
+ const want = region(parse(read('to.html')));
+ check(got === want, 'the page now on screen equals the server-rendered page for that folder');
+ if (got !== want) {
+ const i = [...got].findIndex((c, k) => c !== want[k]);
+ console.log(' got : ...' + got.slice(Math.max(0, i - 80), i + 120) + '\n want: ...' + want.slice(Math.max(0, i - 80), i + 120));
+ }
+ const visited = JSON.parse(win.localStorage.getItem('cdn_visited_v1') || '[]');
+ check(visited.some((e) => e.type === 'folder' && e.path === 'bsc/semester_1'), "the page's own script recorded the visit in History");
+
+ // Back to the page we came from (Turbo restores it), then forward again.
+ step = 'back';
+ win.history.back();
+ await waitFor(() => win.location.pathname === '/bsc' && win.document.querySelector('#file-list a[href="/bsc/semester_1"]'));
+ check(win.location.pathname === '/bsc', 'Back returns to /bsc');
+ check(win.document.querySelector('#file-list a[href="/bsc/semester_1"]') !== null, '...and shows that folder\'s listing');
+
+ // Going to the root from a deep page: header appears, breadcrumb disappears.
+ step = 'root';
+ win.document.querySelector('.wordmark').dispatchEvent(new win.MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }));
+ await waitFor(() => win.location.pathname === '/' && win.document.querySelector('h1'));
+ check(win.location.pathname === '/', 'the logo link goes to the root locally');
+ check(win.document.querySelector('h1') !== null && win.document.querySelector('.breadcrumb-container') === null, 'root shows its header and no breadcrumb');
+ check(region(win.document) === region(parse(read('root.html'))), 'the root page equals the server-rendered root page');
+ check(networkCalls === 0, 'still no page requests (Back, logo link)' + (requested.length ? ': ' + requested.join(', ') : ''));
+
+ console.log(failures.length ? `\n${failures.length} FAILURE(S)` : '\nend-to-end navigation works');
+ process.exit(failures.length ? 1 : 0);
+})().catch((e) => { console.error('FAIL - exception:', e); process.exit(1); });