Listing JSON gains a writable bool per file row, computed by running the policy decider with ActionWrite against the parent-dir chain (with the same admin-bypass branch the file API uses). Cost: one extra decider call per file in the listing, sharing the parent chain so the cascade walk is amortized. Browse loader stores writable on every tree node. The markdown and YAML editors read it and gate their canSave + initial mount: - !writable markdown → Toast UI Viewer (rendered, no edit toolbar, no caret). Banner above explains why save is disabled. - !writable YAML → CodeMirror readOnly:'nocursor' (selection for copy, no caret). Banner above explains why save is disabled. Both editors gain autofocus:false so keyboard nav in the browse tree doesn't divert into the editor — arrow keys keep moving through files and folders without the caret jumping. User clicks (or tabs) into the editor when they actually want to type. .zddc files already route through preview-yaml's isZddcFile path; bare .zddc (no ext) matches because that function checks the literal name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
204 lines
8.8 KiB
JavaScript
204 lines
8.8 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,
|
|
// Server-computed write authority — true if the policy
|
|
// decider would allow a PUT for the calling principal.
|
|
// Absent / false means "save will 403"; preview editors
|
|
// read this to mount in read-only mode.
|
|
writable: !!e.writable,
|
|
// 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();
|
|
// X-ZDDC-On-Plan-Review surfaces whether the cascade above
|
|
// this path has an on_plan_review block. Drives visibility of
|
|
// the "Plan Review" right-click menu item on received/<tracking>/
|
|
// folders.
|
|
window.app.state.scopeOnPlanReview =
|
|
(resp.headers.get('X-ZDDC-On-Plan-Review') || '').toLowerCase() === 'true';
|
|
// X-ZDDC-Canonical-Folder names the canonical project-layout
|
|
// slot this directory occupies — "incoming", "received",
|
|
// "working", "staging", etc. Drives scope-aware menu items:
|
|
// Accept Transmittal (folders under incoming), Stage/Unstage
|
|
// (files under working/staging), Create Transmittal folder
|
|
// (right-click in staging).
|
|
window.app.state.scopeCanonicalFolder =
|
|
(resp.headers.get('X-ZDDC-Canonical-Folder') || '').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
|
|
};
|
|
})();
|