cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
commit 13c5b8df278fadb7ee1377520ce3f93bcb8cfd40
parent 9cc01a2127afae7d1ef7ee7ae141341e3e61b566
Author: bot-for-notamitgamer[bot] <bot-for-notamitgamer[bot]@users.noreply.github.com>
Date:   Sun, 11 Oct 2026 08:57:34 +0000

Instant folder navigation from a locally cached file tree

Prefetching only helps if the user waits a moment; someone who knows where
they're going clicks straight away and still paid a network round trip.
Now the browser keeps the whole file tree and a page shell in localStorage
and builds a folder's page locally, so the click needs no request at all,
even on the first click after load and, for returning visitors, before the
network answers.

How it works
- GET /api/tree: every non-hidden file as [path, size] (ETag, gzip, 304;
  503 on an empty/failed listing; off above FAST_TREE_MAX_FILES).
- GET /api/shell: the folder page skeleton with slots, plus each page
  fragment (rows, toolbar, breadcrumb, ...) with @@placeholders@@.
- static/fasttree.js intercepts plain clicks on folder links, fills the
  fragments from the tree and calls Turbo.visit(url, {response}), so Turbo
  owns history, scroll, Back/Forward and script re-execution. Anything
  unusual falls through to the normal request.
- The tree is revalidated in the background (page load, every 15 s, on
  focus, after an upload). A page built from an older tree is rebuilt.

One source of truth for markup
- Page fragments move into Jinja macros (templates/_listing.html) used by
  both the server render and /api/shell. The refactor was verified against
  17 pre-refactor pages: output identical except the new script tag and the
  upload refresh hook.
- tests/test_fast_tree.py asserts a locally built page equals the server's
  for deep/collapsed breadcrumbs, &'<" and emoji names, size rounding ties.

Also
- prefetch.js skips folder links the local path handles.
- Tests: JS unit + jsdom glue tests, an end-to-end test with the real Turbo
  8.0.4 and real server pages, and Python endpoint/parity tests, all in CI.
  jsdom/Turbo are installed with --no-save in CI only.
- README, CONTRIBUTING, .env.example updated; the README no longer claims
  uploads leave the server cache stale (they clear it).

Diffstat:
M.env.example | 4++++
M.github/workflows/ci.yml | 13+++++++++++--
M.gitignore | 3+++
MCONTRIBUTING.md | 12+++++++++++-
MREADME.md | 65++++++++++++++++++++++++++++++++++++++++++++++-------------------
Aapp/fast_tree.py | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/main.py | 29++++++++++++++++++++++++++++-
Aapp/static/fasttree.js | 343+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/static/prefetch.js | 2++
Aapp/templates/_listing.html | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/templates/_navbar.html | 38+++++++++++++++-----------------------
Mapp/templates/documentation.html | 1+
Mapp/templates/gh_sync_docs.html | 1+
Mapp/templates/index.html | 125++++++++++++++++++++-----------------------------------------------------------
Mapp/templates/stats.html | 1+
Mapp/templates/url_shortener.html | 1+
Atests/compose.js | 13+++++++++++++
Atests/fake_bucket.py | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/fasttree.dom.test.js | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/fasttree.test.js | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_fast_tree.py | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/turbo_e2e.js | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&#34;', "'": '&#39;' }; + // 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 += ' &amp; '; + 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 %} &amp; {% 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">&hellip;</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">&hellip;</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 %} &amp; {% 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>`), '&lt;a href=&#34;x&#34;&gt;&#39;&amp;&#39;&lt;/a&gt;'); + 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> &amp; <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> &amp; <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:&lt;b&gt;.txt|$&amp;/&lt;b&gt;.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); });