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>
180 lines
7.3 KiB
JavaScript
180 lines
7.3 KiB
JavaScript
// download.js — per-node downloads, surfaced through the tree's
|
|
// right-click menu (downloadFile / downloadFolder).
|
|
//
|
|
// downloadFile: a single file. Server mode lets the browser pull
|
|
// node.url (zddc-server emits Content-Disposition); FS-API mode
|
|
// reads bytes through the file handle and blob-downloads.
|
|
//
|
|
// downloadFolder: an arbitrary directory node as a .zip. Server
|
|
// mode points an <a download> at "<node-path>/?zip=1" so zddc-server
|
|
// streams an ACL-filtered archive without buffering on the client.
|
|
// FS-API mode walks the picked handle in two passes — metadata
|
|
// first, then bytes — so we can warn before loading a very large
|
|
// tree into memory.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
|
|
// Soft thresholds for the offline bundle: above either, confirm()
|
|
// before loading everything into memory.
|
|
var WARN_FILE_COUNT = 2000;
|
|
var WARN_TOTAL_BYTES = 500 * 1024 * 1024;
|
|
|
|
function events() { return window.app.modules.events; }
|
|
|
|
function isHiddenName(name) {
|
|
return name.length === 0 || name[0] === '.' || name[0] === '_';
|
|
}
|
|
|
|
function fmtMB(bytes) { return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }
|
|
|
|
// Trigger a browser download of a Blob (revokes the object URL after).
|
|
function downloadBlob(filename, blob) {
|
|
var a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
setTimeout(function () {
|
|
URL.revokeObjectURL(a.href);
|
|
a.remove();
|
|
}, 0);
|
|
}
|
|
|
|
// Trigger a download from a same-origin server URL via Content-Disposition.
|
|
function downloadUrl(filename, url) {
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename; // hint; the server's Content-Disposition wins
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
setTimeout(function () { a.remove(); }, 0);
|
|
}
|
|
|
|
// Recursively collect every (non-hidden) file under dirHandle into
|
|
// `out` as { relPath, handle, size }, accumulating into `tally`.
|
|
// relPrefix is the slash-terminated path within the picked root
|
|
// ("" at the root).
|
|
async function collectFiles(dirHandle, relPrefix, out, tally) {
|
|
for await (var pair of dirHandle.entries()) {
|
|
var name = pair[0];
|
|
var handle = pair[1];
|
|
if (isHiddenName(name)) continue;
|
|
if (handle.kind === 'directory') {
|
|
await collectFiles(handle, relPrefix + name + '/', out, tally);
|
|
} else {
|
|
var size = 0;
|
|
try {
|
|
var f = await handle.getFile();
|
|
size = f.size || 0;
|
|
} catch (_e) { /* permission lost — count it as 0 */ }
|
|
out.push({ relPath: relPrefix + name, handle: handle, size: size });
|
|
tally.count++;
|
|
tally.bytes += size;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function downloadFsSubtree(rootHandle) {
|
|
var ev = events();
|
|
ev.statusInfo('Scanning ' + rootHandle.name + '…');
|
|
var files = [];
|
|
var tally = { count: 0, bytes: 0 };
|
|
await collectFiles(rootHandle, '', files, tally);
|
|
if (files.length === 0) {
|
|
ev.statusInfo(rootHandle.name + ' is empty — nothing to download.');
|
|
return;
|
|
}
|
|
if (tally.count > WARN_FILE_COUNT || tally.bytes > WARN_TOTAL_BYTES) {
|
|
var ok = window.confirm(
|
|
'This folder has ' + tally.count + ' files (~' + fmtMB(tally.bytes) + ').\n\n'
|
|
+ 'Building the zip loads them all into memory — it may be slow or crash the tab.\n\n'
|
|
+ 'Continue?');
|
|
if (!ok) { ev.statusClear(); return; }
|
|
}
|
|
var zip = new window.JSZip();
|
|
for (var i = 0; i < files.length; i++) {
|
|
ev.statusInfo('Zipping ' + rootHandle.name + '… (' + (i + 1) + '/' + files.length + ')');
|
|
var f = await files[i].handle.getFile();
|
|
var buf = await f.arrayBuffer();
|
|
zip.file(rootHandle.name + '/' + files[i].relPath, buf);
|
|
}
|
|
ev.statusInfo('Generating ' + rootHandle.name + '.zip…');
|
|
var blob = await zip.generateAsync({ type: 'blob' });
|
|
downloadBlob(rootHandle.name + '.zip', blob);
|
|
ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)');
|
|
}
|
|
|
|
var busy = false;
|
|
|
|
// Download a single file node. Server mode: rely on the node's
|
|
// own URL (the server emits Content-Disposition). FS mode: read
|
|
// bytes through the handle and trigger a blob download. Works
|
|
// for ordinary files, for .zip members (the loader sets node.url
|
|
// for zip members in server mode and a ZipFileHandle offline),
|
|
// and for the .zip file itself.
|
|
async function downloadFile(node) {
|
|
if (busy) return;
|
|
if (!node || node.isDir) {
|
|
events().statusError('Not a file: ' + (node && node.name));
|
|
return;
|
|
}
|
|
busy = true;
|
|
try {
|
|
if (node.url) {
|
|
events().statusInfo('Downloading ' + node.name + '…');
|
|
downloadUrl(node.name, node.url);
|
|
setTimeout(function () { events().statusClear(); }, 2500);
|
|
} else if (node.handle && typeof node.handle.getFile === 'function') {
|
|
events().statusInfo('Preparing ' + node.name + '…');
|
|
var f = await node.handle.getFile();
|
|
var blob = new Blob([await f.arrayBuffer()]);
|
|
downloadBlob(node.name, blob);
|
|
events().statusInfo('Downloaded ' + node.name);
|
|
} else {
|
|
events().statusError('No download path for ' + node.name);
|
|
}
|
|
} catch (e) {
|
|
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
// Download an arbitrary folder node as a .zip — same dispatch as
|
|
// downloadCurrentSubtree but scoped to the picked node instead of
|
|
// state.currentPath / state.rootHandle. Server mode hits
|
|
// "<node-path>/?zip=1"; FS mode walks the directory handle.
|
|
async function downloadFolder(node) {
|
|
if (busy) return;
|
|
if (!node || !node.isDir) {
|
|
events().statusError('Not a folder: ' + (node && node.name));
|
|
return;
|
|
}
|
|
busy = true;
|
|
try {
|
|
if (state.source === 'server') {
|
|
var tree = window.app.modules.tree;
|
|
var dir = tree.pathFor(node).replace(/\/$/, '');
|
|
events().statusInfo('Preparing ' + node.name + '.zip…');
|
|
downloadUrl(node.name + '.zip', dir + '/?zip=1');
|
|
setTimeout(function () { events().statusClear(); }, 2500);
|
|
} else if (state.source === 'fs' && node.handle
|
|
&& node.handle.kind === 'directory') {
|
|
await downloadFsSubtree(node.handle);
|
|
} else {
|
|
events().statusError('Cannot download ' + node.name);
|
|
}
|
|
} catch (e) {
|
|
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
window.app.modules.download = {
|
|
downloadFile: downloadFile,
|
|
downloadFolder: downloadFolder
|
|
};
|
|
})();
|