ZDDC/browse/js/loader.js
ZDDC 72c0552750 feat(browse): "Show hidden" toggle — list .-prefixed and _-prefixed entries
Adds a UI checkbox next to the existing Sort dropdown that surfaces
hidden entries when ACL would otherwise allow read. Default off
(matches today's filtered behavior). On toggle, browse re-fetches
the current directory with ?hidden=1 and re-renders.

  ┌─ browse toolbar ─────────────────────────────────────────────┐
  │  Sort: [Name (A→Z) ▾]    ☐ Show hidden                       │
  └──────────────────────────────────────────────────────────────┘

Server-side surface:

  - internal/fs/tree.go ListDirectory gains an `includeHidden bool`
    parameter. The .-prefix filter (previously hard-coded) now also
    drops _-prefix entries (matches dispatch's reserved-prefix guard)
    and honors the new flag.
  - internal/handler/directory.go reads `?hidden=1` from the request
    and threads it through.
  - cmd/zddc-server/main.go dispatcher relaxes its dot-prefix and
    _-prefix guards for GET/HEAD when `?hidden=1` is set, so clicking
    a hidden entry's link works. `_app/` (apps cache) stays
    unconditionally reserved — those bytes must go through the apps
    resolver. Writes to hidden paths stay blocked (the file API has
    its own segment check that the flag does NOT relax).
  - internal/listing/listing.go: signature parity (the lower-level
    helper that's used by tests + non-cascade listing paths).

Security model unchanged: the ACL chain on the parent dir is the only
real gate. Whoever can read the dir can see its contents — toggling
"Show hidden" just stops the client-side filter from masking
.-prefixed and _-prefixed entries. Hidden paths today:

  • <dir>/.zddc                ACL YAML — already exposed via /.profile/zddc
  • <dir>/.converted/<base>    cached MD→DOCX/HTML/PDF, same sensitivity as source
  • <root>/.zddc.d/tokens/     per-token metadata; filename = sha256(token)
                               so not bearer-usable. Default root ACL
                               restricts to admins; matches /.tokens UI.
  • <root>/.zddc.d/logs/       access logs; same admins-only audience
  • <root>/_app/               cached upstream tool HTML (public)
  • <root>/_template/          install.zip scaffolding (public)

None of these contain bearer credentials or secret material that the
existing ACL doesn't already gate. The walls are still the cascade.
2026-05-13 14:45:41 -05:00

185 lines
7.6 KiB
JavaScript

// loader.js — fetches directory entries for either source mode.
//
// Server mode: GET <urlPath> with Accept: application/json. zddc-server
// (and Caddy's built-in browse, which we mirror) returns an array of
// FileInfo {name, size, url, mod_time, mode, is_dir, is_symlink}.
//
// FS-API mode: enumerate a FileSystemDirectoryHandle's children. No
// network involved; works on local folders the user picked.
(function () {
'use strict';
var state = window.app.state;
function splitExt(name) {
var i = name.lastIndexOf('.');
if (i <= 0 || i === name.length - 1) return '';
return name.substring(i + 1).toLowerCase();
}
// Build a raw entry from the server's FileInfo shape.
function fromServerEntry(e) {
// Server returns directory names with a trailing "/". Strip
// it for display; the is_dir flag is the canonical signal.
var name = e.is_dir ? e.name.replace(/\/$/, '') : e.name;
// displayName is the friendlier label set by the parent .zddc
// `display:` map (when present). The on-disk basename stays in
// .name so URL composition (pathFor) and the chevron's title
// attribute still reflect the real folder name.
var displayName = (typeof e.display_name === 'string' && e.display_name)
? e.display_name
: '';
return {
name: name,
displayName: displayName,
isDir: e.is_dir,
size: e.size || 0,
modTime: e.mod_time ? new Date(e.mod_time) : null,
ext: e.is_dir ? '' : splitExt(name),
url: e.url || null,
// FS-API specific (null in server mode):
handle: null
};
}
// Build a raw entry from a FileSystemHandle.
async function fromHandle(handle) {
var name = handle.name;
var isDir = handle.kind === 'directory';
var size = 0;
var modTime = null;
if (!isDir) {
try {
var f = await handle.getFile();
size = f.size;
modTime = new Date(f.lastModified);
} catch (_e) {
// permission lost; leave size/modTime defaults
}
}
return {
name: name,
isDir: isDir,
size: size,
modTime: modTime,
ext: isDir ? '' : splitExt(name),
url: null,
handle: handle
};
}
// Fetch children of a directory in server mode.
// path must end with '/' so the request hits the directory route.
//
// 404 is treated as "empty directory" rather than a hard error.
// A directory that doesn't exist on the server (e.g. a fresh
// project's working/ before any drafts have been created, or a
// dir deleted between listing and expand) is functionally
// indistinguishable from an empty one for tree-rendering purposes.
// Server-side, zddc-server already returns 200 + [] for canonical
// project folders that are missing on disk; this fallback covers
// the same UX for anything else and for non-zddc-server backends.
async function fetchServerChildren(path) {
if (!path.endsWith('/')) path += '/';
// ?hidden=1 surfaces .-prefixed and _-prefixed entries when the
// user has flipped the "Show hidden" toggle. The server still
// ACL-gates per-entry, so this is purely additive — anyone
// without read on the parent dir already sees nothing.
var url = path;
if (window.app && window.app.state && window.app.state.showHidden) {
url += (url.indexOf('?') === -1 ? '?' : '&') + 'hidden=1';
}
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
// Capture cascade-resolved scope flags from response headers
// before bailing on 404. zddc-server emits X-ZDDC-Drop-Target
// for directories the cascade marks as upload destinations
// (see zddc/internal/zddc/lookups.go DropTargetAt). The flag
// is leaf-only — it describes THIS path, not its descendants
// — so a rescope or popstate re-reads it from the new listing.
var dropTargetHdr = (resp.headers.get('X-ZDDC-Drop-Target') || '').toLowerCase();
window.app.state.scopeDropTarget = dropTargetHdr === 'true';
// X-ZDDC-Default-Tool surfaces the cascade-resolved default
// tool name for the current path. Browse uses it to decide
// grid-mode auto-activation (when default_tool==classifier)
// without re-implementing the cascade client-side.
window.app.state.scopeDefaultTool =
(resp.headers.get('X-ZDDC-Default-Tool') || '').toLowerCase();
if (resp.status === 404) {
return [];
}
if (!resp.ok) {
throw new Error('HTTP ' + resp.status + ' fetching ' + path);
}
var data = await resp.json();
if (!Array.isArray(data)) {
throw new Error('Unexpected response shape from ' + path);
}
return data.map(fromServerEntry);
}
// Enumerate a FileSystemDirectoryHandle's immediate children.
async function fetchFsChildren(dirHandle) {
var entries = [];
for await (var [_name, handle] of dirHandle.entries()) {
entries.push(await fromHandle(handle));
}
return entries;
}
// Probe whether THIS page is being served by zddc-server (or any
// server that responds to JSON listing requests). If so, switch to
// server mode automatically and load the current directory.
async function autoDetectServerMode() {
// Only attempt when running over http(s) and the location's
// path looks like a directory. Probing on file:// is pointless.
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return false;
}
// Strip any /<tool>.html from the path to get the directory.
var path = location.pathname;
// If the URL points at the browse.html itself, the directory
// is the parent. If it's a directory ending in '/', use it.
var dirPath;
if (path.endsWith('/')) {
dirPath = path;
} else {
// e.g. '/some/dir/browse.html' → '/some/dir/'
var slash = path.lastIndexOf('/');
dirPath = slash >= 0 ? path.substring(0, slash + 1) : '/';
}
try {
var entries = await fetchServerChildren(dirPath);
state.source = 'server';
state.currentPath = dirPath;
return { entries: entries, path: dirPath };
} catch (_e) {
// Not a server-backed page (e.g. opened via file://).
return null;
}
}
// JSZip is vendored into the bundle (shared/vendor/jszip.min.js
// is concatenated ahead of init.js by build.sh), so it's always
// already attached to window.JSZip by the time any tree code runs.
// We keep the helper because tree.js calls it before reaching for
// window.JSZip; if the bundle is ever rebuilt without the vendor
// copy this will throw a clear error rather than silently failing.
function ensureJSZip() {
if (window.JSZip) return Promise.resolve();
return Promise.reject(new Error(
'JSZip not bundled — rebuild browse with shared/vendor/jszip.min.js'));
}
// Public API
window.app.modules.loader = {
fetchServerChildren: fetchServerChildren,
fetchFsChildren: fetchFsChildren,
autoDetectServerMode: autoDetectServerMode,
splitExt: splitExt,
ensureJSZip: ensureJSZip
};
})();