ZDDC/browse/js/upload.js
ZDDC b2c16063c4 refactor(browse): remove dead code, document state shape
Pure cleanup, no behavior change:

- tree.js: drop the unused setSort() method (only setSortExplicit is wired,
  via the toolbar dropdown) and its doubly-stale comment (claimed there was
  no sort UI — there is).
- app.js: remove the augmentRoot/passThroughEntries identity stub. It was a
  leftover from when browse merged virtual canonical folders client-side;
  zddc-server emits them now and nothing reads window.app.modules.augmentRoot.
- loader.js: splitExt now delegates to window.zddc.splitExtension (identical
  behavior — lowercased, dotfile/trailing-dot → '') per the CLAUDE.md rule
  that extension handling goes through window.zddc; drop the unused export.
- upload.js: remove the dead `else if (refreshUrl)` comment-only branch (and
  the unused refreshUrl var) — refreshListing is always present since it was
  exported.
- init.js: declare scopeCanonicalFolder, scopeOnPlanReview, and showHidden in
  the state initializer. They were read/written across modules but never
  listed in the canonical state shape (implicit undefined).

All 6 browse Playwright specs pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:32:05 -05:00

596 lines
24 KiB
JavaScript

// upload.js — drag-drop file upload into the current scope.
//
// Active only in server mode and only at paths where the cascade
// declares drop_target: true (see zddc/internal/zddc/lookups.go
// DropTargetAt + defaults.zddc.yaml). The loader captures the
// X-ZDDC-Drop-Target response header on every directory listing
// fetch and stamps state.scopeDropTarget; this module just reads it.
//
// At scopes where drop_target is false (or unset), the handlers
// stay armed but ignore drops silently — no visible drop-zone
// overlay. An operator can flip working/staging/incoming on or
// extend the cascade to mark additional dirs as drop targets via
// .zddc; the client follows automatically without code change.
//
// Wire model:
// - dragenter on the document raises a counter; first-enter shows
// the overlay.
// - dragleave decrements; reaching zero hides the overlay.
// - drop short-circuits: prevent default, PUT each file under the
// current state.currentPath, surface per-file toast results,
// refetch the listing on completion.
//
// The PUT uses fetch(`<currentPath><filename>`, method: 'PUT'). The
// server's authorizeAction enforces write ACL on the parent; a 403
// surfaces as an error toast and the rest of the batch proceeds.
//
// Per-file size cap (UPLOAD_MAX_BYTES): files larger than the cap
// are rejected client-side with a clear toast — the server would
// accept them in chunks but browse's v1 PUT is a single body, and
// dropping a 4 GB CAD bundle into the browser tab as a Blob is a
// poor experience. Operators with larger uploads should use a
// dedicated client (zddc-cli or the cache/mirror downstream).
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
var UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MiB per file
var state = window.app.state;
var enterCount = 0;
var overlayEl = null;
function ensureOverlay() {
if (overlayEl) return overlayEl;
overlayEl = document.createElement('div');
overlayEl.className = 'upload-overlay';
overlayEl.setAttribute('aria-hidden', 'true');
overlayEl.innerHTML =
'<div class="upload-overlay__panel">'
+ '<div class="upload-overlay__icon">⤴</div>'
+ '<div class="upload-overlay__title">Drop to upload</div>'
+ '<div class="upload-overlay__path" id="uploadOverlayPath"></div>'
+ '</div>';
document.body.appendChild(overlayEl);
return overlayEl;
}
function currentScopeAllows() {
if (!state || state.source !== 'server') return false;
// state.scopeDropTarget is set by the loader on every listing
// fetch from the X-ZDDC-Drop-Target response header; it's a
// boolean read of the cascade's effective drop_target flag at
// the current path. Defaults to false when the header is
// absent (older server or non-server response).
return !!state.scopeDropTarget;
}
function showOverlay() {
var el = ensureOverlay();
var pathEl = el.querySelector('#uploadOverlayPath');
if (pathEl) pathEl.textContent = state.currentPath || '/';
el.classList.add('is-active');
}
function hideOverlay() {
if (overlayEl) overlayEl.classList.remove('is-active');
}
function dragHasFiles(e) {
if (!e.dataTransfer || !e.dataTransfer.types) return false;
var types = e.dataTransfer.types;
for (var i = 0; i < types.length; i++) {
if (types[i] === 'Files') return true;
}
return false;
}
// Join a directory path and a relative path safely. dir is expected
// to be /-prefixed and may or may not have a trailing /; rel is a
// forward-slash relative path (no leading /). Each segment is
// URI-encoded so spaces and friends survive the round trip.
function joinUrl(dir, rel) {
var base = dir || '/';
if (!base.endsWith('/')) base += '/';
return base + rel.split('/').map(encodeURIComponent).join('/');
}
async function uploadOne(file, destDir, relPath) {
if (file.size > UPLOAD_MAX_BYTES) {
return {
file: file,
ok: false,
status: 0,
message: 'too large (max ' + Math.round(UPLOAD_MAX_BYTES / 1024 / 1024) + ' MiB)'
};
}
try {
var resp = await fetch(joinUrl(destDir, relPath), {
method: 'PUT',
body: file,
credentials: 'same-origin',
headers: {
'Content-Type': file.type || 'application/octet-stream'
}
});
return {
file: file,
ok: resp.ok,
status: resp.status,
message: resp.ok ? '' : ('HTTP ' + resp.status)
};
} catch (e) {
return {
file: file,
ok: false,
status: 0,
message: (e && e.message) ? e.message : 'network error'
};
}
}
// ── Folder-upload helpers (webkitGetAsEntry recursion) ─────────────────
// Browsers expose dropped folders only through the entries API.
// walkEntry flattens a tree into [{ relPath, file }] so uploadOne
// can PUT each file individually. The server's PUT auto-creates
// intermediate directories, so no explicit mkdir is needed.
function readAllEntries(reader) {
return new Promise(function (resolve, reject) {
var collected = [];
function loop() {
reader.readEntries(function (batch) {
if (batch.length === 0) return resolve(collected);
collected = collected.concat(batch);
loop();
}, reject);
}
loop();
});
}
function entryToFile(entry) {
return new Promise(function (resolve, reject) {
entry.file(resolve, reject);
});
}
async function walkEntry(entry, prefix, out) {
if (entry.isFile) {
try {
var f = await entryToFile(entry);
out.push({ relPath: prefix + entry.name, file: f });
} catch (_e) { /* skip unreadable file */ }
} else if (entry.isDirectory) {
var reader = entry.createReader();
var kids = await readAllEntries(reader);
for (var i = 0; i < kids.length; i++) {
await walkEntry(kids[i], prefix + entry.name + '/', out);
}
}
}
// Extract { relPath, file } pairs from a DataTransfer. Uses
// webkitGetAsEntry when available (so folder uploads work);
// falls back to dataTransfer.files for cases where entries
// aren't exposed (some browsers / cross-origin).
async function collectUploads(dt) {
var out = [];
if (dt.items && dt.items.length) {
var entries = [];
for (var i = 0; i < dt.items.length; i++) {
var item = dt.items[i];
if (item.kind !== 'file') continue;
var entry = typeof item.webkitGetAsEntry === 'function'
? item.webkitGetAsEntry()
: null;
if (entry) {
entries.push(entry);
} else {
var f = item.getAsFile();
if (f) out.push({ relPath: f.name, file: f });
}
}
for (var j = 0; j < entries.length; j++) {
await walkEntry(entries[j], '', out);
}
if (out.length) return out;
}
if (dt.files) {
for (var k = 0; k < dt.files.length; k++) {
out.push({ relPath: dt.files[k].name, file: dt.files[k] });
}
}
return out;
}
// Run a batch of uploads against an arbitrary destination directory.
// Surfaces per-file errors as toasts; refreshes the tree afterward
// so newly-uploaded entries appear. Returns { ok, fail } counts.
async function uploadBatch(uploads, destDir) {
var note = window.zddc && window.zddc.toast;
if (note) {
note('Uploading ' + uploads.length + ' item'
+ (uploads.length === 1 ? '' : 's') + '…', 'info');
}
var ok = 0, fail = 0;
for (var i = 0; i < uploads.length; i++) {
var u = uploads[i];
var res = await uploadOne(u.file, destDir, u.relPath);
if (res.ok) ok++;
else {
fail++;
if (note) {
note('Upload failed: ' + u.relPath + ' — ' + res.message, 'error');
}
}
}
if (note) {
if (fail === 0) {
note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's')
+ ' → ' + destDir, 'success');
} else if (ok === 0) {
note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
} else {
note(ok + ' uploaded, ' + fail + ' failed', 'warning');
}
}
return { ok: ok, fail: fail };
}
// Comment upload: PUT each dropped file's bytes to the target URL.
// The server detects the virtual <workflow>/received/ context and
// rewrites the destination to <workflow>/<base>+C<n><suffix>, surfacing
// the resolved path in X-ZDDC-Resolved-Path so the status line can
// tell the user where the bytes landed.
async function uploadCommentToTarget(targetURL, dataTransfer) {
var note = window.zddc && window.zddc.toast;
var files = [];
if (dataTransfer.files && dataTransfer.files.length) {
for (var k = 0; k < dataTransfer.files.length; k++) {
files.push(dataTransfer.files[k]);
}
}
if (files.length === 0) {
if (note) note('No files to upload.', 'warning');
return;
}
var ok = 0;
var lastResolved = '';
for (var i = 0; i < files.length; i++) {
var f = files[i];
if (f.size > UPLOAD_MAX_BYTES) {
if (note) note('Skipped (too large): ' + f.name, 'error');
continue;
}
try {
var resp = await fetch(targetURL, {
method: 'PUT',
body: f,
credentials: 'same-origin',
headers: { 'Content-Type': f.type || 'application/octet-stream' }
});
if (resp.ok) {
ok++;
var hdr = resp.headers.get('X-ZDDC-Resolved-Path') || '';
if (hdr) lastResolved = hdr;
} else if (note) {
note('Comment upload failed (' + resp.status + ')', 'error');
}
} catch (e) {
if (note) note('Comment upload error: ' + (e && e.message), 'error');
}
}
if (note && ok > 0) {
var msg = 'Saved ' + ok + ' comment' + (ok === 1 ? '' : 's');
if (lastResolved) msg += ' — last at ' + lastResolved;
note(msg, 'success');
}
// Reload the current listing so the new +Cn file appears in the
// tree. Best-effort.
try {
var ev = window.app.modules.events;
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
} catch (_e) { /* refresh is best-effort */ }
}
// ── Write-permission escalation (FS-API mode) ──────────────────────────
// The local folder is picked read-only (showDirectoryPicker mode:read)
// so browsing never prompts. The first mutation escalates to readwrite
// via the FS-Access permission prompt; granting on the picked root
// covers every descendant handle. Must run under a user gesture — every
// caller is reached from a click/menu action. No-op in server mode or
// on browsers without the permission API.
async function ensureWritable() {
if (state.source !== 'fs') return;
var root = state.rootHandle;
if (!root || typeof root.requestPermission !== 'function') return;
var opts = { mode: 'readwrite' };
if ((await root.queryPermission(opts)) === 'granted') return;
if ((await root.requestPermission(opts)) === 'granted') return;
throw new Error('Write permission denied — grant edit access to the folder when prompted.');
}
// handleForDir resolves a directory PATH (FS-API mode) to its
// FileSystemDirectoryHandle: the picked root for the current scope,
// else the matching expanded node's handle. Returns null if unknown.
function handleForDir(dirPath) {
var tree = window.app.modules.tree;
if (!dirPath.endsWith('/')) dirPath += '/';
if (dirPath === state.currentPath) return state.rootHandle;
var noSlash = dirPath.replace(/\/$/, '');
var hit = null;
state.nodes.forEach(function (n) {
if (hit || !n.isDir || !n.handle) return;
if (tree && tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
});
return hit ? hit.handle : null;
}
// ── Create-new helpers ────────────────────────────────────────────────
// Server mode: PUT for files (empty/template body) and POST +
// X-ZDDC-Op: mkdir for directories; the server's ACL is the source of
// truth. FS-API mode: create directly in the picked tree via
// getDirectoryHandle/getFileHandle({create:true}) + createWritable —
// limited only by the filesystem permission the user granted.
async function makeDir(parentDir, name) {
if (state.source === 'server') {
var url = joinUrl(parentDir, name);
if (!url.endsWith('/')) url += '/';
var resp = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-ZDDC-Op': 'mkdir' }
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
var parent = handleForDir(parentDir);
if (!parent) throw new Error('No directory handle for ' + parentDir);
await ensureWritable();
await parent.getDirectoryHandle(name, { create: true });
}
async function makeFile(parentDir, name, body, contentType) {
if (state.source === 'server') {
var resp = await fetch(joinUrl(parentDir, name), {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': contentType || 'application/octet-stream' },
body: body == null ? '' : body
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
var parent = handleForDir(parentDir);
if (!parent) throw new Error('No directory handle for ' + parentDir);
await ensureWritable();
var fh = await parent.getFileHandle(name, { create: true });
var w = await fh.createWritable();
await w.write(body == null ? '' : body);
await w.close();
}
// ── Delete + rename ─────────────────────────────────────────────────────
// Both run through the same FS Access API + file-API endpoints used
// by the create helpers above:
// - Server mode: DELETE / POST X-ZDDC-Op: move. ACL is enforced
// server-side; a 403/405 surfaces as an error toast.
// - FS-API mode: FileSystemHandle.remove({recursive:true}) and
// .move(newName) — both are Chromium-110+ features. We feature-
// detect at the handle level; callers see a clear "not supported"
// error message if the browser is too old.
function pathForNode(node) {
var tree = window.app.modules.tree;
return tree ? tree.pathFor(node) : '';
}
function isZipMember(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && state.source === 'server' && /\.zip\//i.test(node.url)) {
return true;
}
return false;
}
// True when this node's write API is reachable. The server can
// still refuse the action on ACL grounds; this only gates the
// menu's disabled-state for the cases where there's clearly no
// write target at all.
function canMutate(node) {
if (!node || node.virtual) return false;
if (isZipMember(node)) return false;
if (state.source === 'server') return true;
if (node.handle && typeof node.handle.remove === 'function') return true;
return false;
}
async function removeNode(node) {
if (!node) throw new Error('no node');
if (isZipMember(node)) {
throw new Error('Cannot delete a file inside a zip archive.');
}
if (node.virtual) {
throw new Error('Virtual folder — nothing on disk to delete.');
}
if (state.source === 'server') {
var url = pathForNode(node);
if (node.isDir && !url.endsWith('/')) url += '/';
var resp = await fetch(url, {
method: 'DELETE',
credentials: 'same-origin'
});
if (!resp.ok) {
if (resp.status === 403) throw new Error('Permission denied (403).');
if (resp.status === 405) throw new Error('Delete not allowed for this entry.');
throw new Error('HTTP ' + resp.status);
}
return;
}
// FS-API path. FileSystemHandle.remove() is Chromium 110+
// (browsers that didn't ship it expose no equivalent — the
// legacy removeEntry() lives on the PARENT directory handle
// and we don't retain ancestor handles).
if (node.handle && typeof node.handle.remove === 'function') {
await ensureWritable();
await node.handle.remove({ recursive: !!node.isDir });
return;
}
throw new Error('Delete not supported by this browser in offline mode.');
}
async function renameNode(node, newName) {
if (!node) throw new Error('no node');
if (!newName) throw new Error('Name required.');
if (newName === node.name) return;
if (isZipMember(node)) {
throw new Error('Cannot rename a file inside a zip archive.');
}
if (node.virtual) {
throw new Error('Virtual folder — nothing on disk to rename.');
}
if (state.source === 'server') {
var src = pathForNode(node);
if (node.isDir && !src.endsWith('/')) src += '/';
// Destination = same parent, new basename.
var lastSlash = src.replace(/\/$/, '').lastIndexOf('/');
var parent = lastSlash >= 0 ? src.substring(0, lastSlash + 1) : '/';
var dst = parent + encodeURIComponent(newName) + (node.isDir ? '/' : '');
var resp = await fetch(src, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-ZDDC-Op': 'move',
'X-ZDDC-Destination': dst
}
});
if (!resp.ok) {
if (resp.status === 403) throw new Error('Permission denied (403).');
if (resp.status === 409) throw new Error('A file with that name already exists.');
throw new Error('HTTP ' + resp.status);
}
return;
}
// FS-API: handle.move(newName) is Chromium 110+.
if (node.handle && typeof node.handle.move === 'function') {
await ensureWritable();
await node.handle.move(newName);
return;
}
throw new Error('Rename not supported by this browser in offline mode.');
}
// Refresh either the root listing (when the upload targeted the
// current scope) or just one folder node's children (when the
// upload targeted a subfolder via a per-row drop).
async function refreshAfterUpload(targetDir) {
var loader = window.app.modules.loader;
var tree = window.app.modules.tree;
if (!loader || !tree) return;
if (state.currentPath && targetDir === state.currentPath) {
try {
var es = await loader.fetchServerChildren(state.currentPath);
tree.setRoot(es);
tree.render();
} catch (_e) { /* swallow */ }
return;
}
// Find any tree node whose path matches targetDir and reload
// its children. Walks state.nodes flat — n is small enough for
// a linear scan.
var dirNoSlash = (targetDir || '').replace(/\/$/, '');
var hit = null;
state.nodes.forEach(function (n) {
if (hit || !n.isDir) return;
if (tree.pathFor(n).replace(/\/$/, '') === dirNoSlash) hit = n;
});
if (hit && hit.expanded) {
try {
var raw = await loader.fetchServerChildren(targetDir);
tree.setChildren(hit.id, raw);
tree.render();
} catch (_e) { /* swallow */ }
}
}
// Document-level drop: targets the currently-viewed scope. The
// per-row drop (events.js) calls uploadToDir directly with a
// different destination.
async function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
enterCount = 0;
hideOverlay();
if (!currentScopeAllows()) return;
var dt = e.dataTransfer;
if (!dt) return;
var uploads = await collectUploads(dt);
if (!uploads.length) return;
await uploadBatch(uploads, state.currentPath);
await refreshAfterUpload(state.currentPath);
}
// Public entry for per-row drops or programmatic uploads. destDir
// must be a server path (/-prefixed, slash-terminated optional).
async function uploadToDir(destDir, dataTransfer) {
var uploads = await collectUploads(dataTransfer);
if (!uploads.length) return { ok: 0, fail: 0 };
var res = await uploadBatch(uploads, destDir);
await refreshAfterUpload(destDir);
return res;
}
function onEnter(e) {
if (!dragHasFiles(e)) return;
enterCount++;
if (enterCount === 1 && currentScopeAllows()) {
showOverlay();
}
}
function onLeave(e) {
if (!dragHasFiles(e)) return;
enterCount = Math.max(0, enterCount - 1);
if (enterCount === 0) hideOverlay();
}
function onOver(e) {
if (!dragHasFiles(e)) return;
// preventDefault on dragover is required for drop to fire.
e.preventDefault();
if (e.dataTransfer && currentScopeAllows()) {
e.dataTransfer.dropEffect = 'copy';
} else if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'none';
}
}
function init() {
document.addEventListener('dragenter', onEnter, false);
document.addEventListener('dragleave', onLeave, false);
document.addEventListener('dragover', onOver, false);
document.addEventListener('drop', handleDrop, false);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.app.modules.upload = {
currentScopeAllows: currentScopeAllows,
uploadToDir: uploadToDir,
uploadCommentToTarget: uploadCommentToTarget,
makeDir: makeDir,
makeFile: makeFile,
removeNode: removeNode,
renameNode: renameNode,
canMutate: canMutate,
ensureWritable: ensureWritable,
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
};
})();