Major upgrade to the browse tool's UX, plus a few shared modules other tools can adopt. User-facing: - Right-click context menu on tree rows AND empty pane space. Traditional file-manager grouping (Open / Download / New / Rename-Delete / Copy / Tree ops / View). Items stay visible but disabled when not applicable so muscle memory carries. Generic shared/context-menu.js framework supports normal items, toggles, submenus, separators, danger styling. - YAML editor for .yaml / .yml / .zddc files (CodeMirror 5 vendored at shared/vendor/codemirror-yaml.min.*). js-yaml lint on every change for parse errors. For .zddc cascade files, an additional schema-aware lint pass flags unknown keys, bad enum values, and wrong types. - Per-row drag-drop upload using webkitGetAsEntry (folder uploads work recursively). Per-row drop indicator; doc-level overlay still fires for blank-space drops at drop_target scopes. - New folder / New markdown file context-menu items (server mode). Rename + Delete with native confirm() dialog. File-API helpers removeNode / renameNode use the existing PUT/POST/DELETE endpoints. - Hover info card with the row's full metadata (ZDDC fields + filesystem info + path/URL). Interactive — mouse into it, drag-select text, Ctrl/Cmd-C or right-click → Copy. 200ms grace before dismiss. - Autofilter input at the top of the tree pane. Same grammar as archive's column filters (zddc.filter.parse / matches). Filters files; folders without matches collapse out. Non-matching folders force-open visually when descendants match, without mutating the user's actual expand state. - Two-line ZDDC label: title-first, tracking/rev/status as monospace meta below. Icon column anchors to the title line. Chevron is a Lucide outline `chevron-right` SVG, rotated 90° on `.expanded`. - File-type Lucide icon sprite (shared/icons.js — 16 outline glyphs, ~5 KB). PDF / Word / Spreadsheet / Slides / Image / Video / Audio / CAD / Web / Config / Code / Archive get distinct icons; folders tinted with --primary. - Header wraps gracefully at narrow viewports (shared/base.css flex-wrap + title min-width:0 ellipsis). Body becomes flex column in browse so a wrapping header doesn't break #appMain height. - Markdown editor opens in WYSIWYG mode by default. YAML front-matter + TOC sidebar reworked: flexbox layout (single visible resizer between FM and TOC), both bodies overflow:auto for X+Y scrollbars. - `?file=<path>` deep links open browse pre-positioned at a specific file. Multi-segment paths walk into subdirectories on the way. Auto-flips Show hidden when a segment is dot/underscore-prefixed. - Refresh + show-hidden toggle preserve expansion / selection / preview pinning. Path-keyed snapshot survives a re-fetched listing. - "Add Local Directory" → "Use Local Directory" across the four tools that have it (browse, archive, classifier, +transmittal comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
485 lines
19 KiB
JavaScript
485 lines
19 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 };
|
|
}
|
|
|
|
// ── Create-new helpers ────────────────────────────────────────────────
|
|
// Both go through the same server endpoints used by upload: PUT
|
|
// for files (with an empty/template body) and POST + X-ZDDC-Op:
|
|
// mkdir for directories. Client-side enforcement is best-effort;
|
|
// the server's ACL is the source of truth.
|
|
|
|
async function makeDir(parentDir, name) {
|
|
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);
|
|
}
|
|
|
|
async function makeFile(parentDir, name, body, contentType) {
|
|
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);
|
|
}
|
|
|
|
// ── 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 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 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,
|
|
makeDir: makeDir,
|
|
makeFile: makeFile,
|
|
removeNode: removeNode,
|
|
renameNode: renameNode,
|
|
canMutate: canMutate,
|
|
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
|
|
};
|
|
})();
|