Compare commits

..

No commits in common. "d524966f0033c1e6dbcdffe9a6bd8da45cbca27c" and "2f211d748f7bc075912344669115a345e193ba4b" have entirely different histories.

18 changed files with 424 additions and 633 deletions

View file

@ -63,7 +63,6 @@ concat_files \
"../shared/icons.js" \ "../shared/icons.js" \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"js/init.js" \ "js/init.js" \
"js/util.js" \
"js/loader.js" \ "js/loader.js" \
"js/tree.js" \ "js/tree.js" \
"js/preview.js" \ "js/preview.js" \

View file

@ -25,10 +25,28 @@
if (t) t(msg, level || 'info'); if (t) t(msg, level || 'info');
} }
var util = window.app.modules.util; function isoDateToday() {
var escapeHtml = util.escapeHtml; var d = new Date();
var isoDateToday = util.isoDateToday; return d.getFullYear()
var isoDatePlus = util.isoDatePlus; + '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
function isoDatePlus(days) {
var d = new Date();
d.setDate(d.getDate() + days);
return d.getFullYear()
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
// Is this node a direct child of an incoming/ canonical folder // Is this node a direct child of an incoming/ canonical folder
// AND a well-formed transmittal folder? The first half is the // AND a well-formed transmittal folder? The first half is the
@ -78,7 +96,19 @@
return out; return out;
} }
var fetchPeopleSuggestions = util.fetchAccessEmails; function fetchPeopleSuggestions() {
return fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (r) {
if (!r.ok) return [];
return r.json().then(function (data) {
var out = [];
if (data && data.email) out.push(data.email);
return out;
});
}).catch(function () { return []; });
}
function openForm(initial) { function openForm(initial) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@ -159,15 +189,7 @@
}); });
}); });
// Bind the Escape handler once and remove it in close() — every
// dismissal path (cancel, overlay-click, submit, Escape) routes
// through close(), so the document listener can't outlive the
// modal.
function onKeydown(e) {
if (e.key === 'Escape') { close(); reject(new Error('cancelled')); }
}
function close() { function close() {
document.removeEventListener('keydown', onKeydown);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay); if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
} }
box.querySelector('#acc-cancel').addEventListener('click', function () { box.querySelector('#acc-cancel').addEventListener('click', function () {
@ -176,7 +198,12 @@
overlay.addEventListener('click', function (e) { overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); reject(new Error('cancelled')); } if (e.target === overlay) { close(); reject(new Error('cancelled')); }
}); });
document.addEventListener('keydown', onKeydown); document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
close(); reject(new Error('cancelled'));
}
});
box.querySelector('#acc-submit').addEventListener('click', function () { box.querySelector('#acc-submit').addEventListener('click', function () {
var values = { var values = {
@ -200,7 +227,9 @@
}); });
} }
var quote = util.yamlQuote; function quote(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
function buildBody(values) { function buildBody(values) {
var lines = ['received_date: ' + values.receivedDate]; var lines = ['received_date: ' + values.receivedDate];
if (values.setupPlanReview) { if (values.setupPlanReview) {
@ -214,10 +243,7 @@
return lines.join('\n'); return lines.join('\n');
} }
var busy = false;
async function invoke(node) { async function invoke(node) {
if (busy) return;
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return; if (!tree) return;
var url = tree.pathFor(node); var url = tree.pathFor(node);
@ -249,8 +275,6 @@
return; return;
} }
busy = true;
try {
status('Accept Transmittal — submitting…'); status('Accept Transmittal — submitting…');
var resp; var resp;
try { try {
@ -278,14 +302,7 @@
+ (data && data.received_path ? data.received_path : 'received/'); + (data && data.received_path ? data.received_path : 'received/');
if (data && data.merged) msg += ' (merged with existing tracking)'; if (data && data.merged) msg += ' (merged with existing tracking)';
if (data && data.plan_review) msg += ' · Plan Review scaffolded'; if (data && data.plan_review) msg += ' · Plan Review scaffolded';
status(msg, 'success'); status(msg + ' — reload to see the move.', 'success');
// Refresh the incoming/ listing so the now-moved folder drops out
// of the tree — the stale entry was the main re-trigger hazard.
var ev = window.app.modules.events;
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
} finally {
busy = false;
}
} }
window.app.modules.acceptTransmittal = { window.app.modules.acceptTransmittal = {

View file

@ -8,6 +8,17 @@
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
var events = window.app.modules.events; var events = window.app.modules.events;
// Virtual canonical folder injection used to live here (browse
// appended archive/working/staging/reviewing entries at a project
// root when missing). zddc-server now emits them in the listing
// directly so the .zddc `display:` map can override their labels
// the same as real entries. This pass-through stub keeps the
// events.js rescope contract intact without doing any merging.
function passThroughEntries(entries) { return entries; }
// Expose for events.js's client-side rescope on dblclick.
window.app.modules.augmentRoot = passThroughEntries;
// Walk a `?file=` path segment-by-segment from the current root. // Walk a `?file=` path segment-by-segment from the current root.
// Each non-leaf segment is matched against the parent's children // Each non-leaf segment is matched against the parent's children
// by name; if found and it's a folder, expand+load it (so its // by name; if found and it's a folder, expand+load it (so its
@ -121,26 +132,15 @@
var popQS = new URLSearchParams(location.search); var popQS = new URLSearchParams(location.search);
if (popQS.get('hidden') === '1') window.app.state.showHidden = true; if (popQS.get('hidden') === '1') window.app.state.showHidden = true;
else window.app.state.showHidden = false; else window.app.state.showHidden = false;
// Join the shared nav token: rapid back/forward (or back/forward
// while an in-tool rescope is mid-flight) must not apply a stale
// listing on top of a newer one.
var seq = events.beginNav ? events.beginNav() : 0;
try { try {
var es = await loader.fetchServerChildren(path); var es = await loader.fetchServerChildren(path);
if (events.isCurrentNav && !events.isCurrentNav(seq)) return;
window.app.state.currentPath = path; window.app.state.currentPath = path;
window.app.state.selectedId = null; window.app.state.selectedId = null;
window.app.state.lastPreviewedNodeId = null; window.app.state.lastPreviewedNodeId = null;
tree.setRoot(es); tree.setRoot(es);
tree.render(); tree.render();
// Route through clearPreview so a live editor is disposed
// (not leaked) when back/forward swaps scope.
var pmod = window.app.modules.preview;
if (pmod && pmod.clearPreview) pmod.clearPreview();
else {
var previewBody = document.getElementById('previewBody'); var previewBody = document.getElementById('previewBody');
if (previewBody) previewBody.innerHTML = ''; if (previewBody) previewBody.innerHTML = '';
}
var previewTitle = document.getElementById('previewTitle'); var previewTitle = document.getElementById('previewTitle');
if (previewTitle) previewTitle.textContent = 'No file selected'; if (previewTitle) previewTitle.textContent = 'No file selected';
// Reapply view mode for the new URL (incoming/ → grid, etc). // Reapply view mode for the new URL (incoming/ → grid, etc).

View file

@ -18,9 +18,17 @@
var t = window.zddc && window.zddc.toast; var t = window.zddc && window.zddc.toast;
if (t) t(msg, level || 'info'); if (t) t(msg, level || 'info');
} }
var util = window.app.modules.util; function escapeHtml(s) {
var escapeHtml = util.escapeHtml; return String(s).replace(/[&<>"']/g, function (c) {
var isoDateToday = util.isoDateToday; return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c];
});
}
function isoDateToday() {
var d = new Date();
return d.getFullYear()
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
function openForm() { function openForm() {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@ -70,22 +78,19 @@
input.addEventListener('input', revalidate); input.addEventListener('input', revalidate);
revalidate(); revalidate();
// Escape handler bound once, removed in close() so it can't function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
// outlive a modal dismissed via cancel / overlay-click / submit.
function onKeydown(e) {
if (e.key === 'Escape') { close(); reject(new Error('cancelled')); }
}
function close() {
document.removeEventListener('keydown', onKeydown);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
}
box.querySelector('#ct-cancel').addEventListener('click', function () { box.querySelector('#ct-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled')); close(); reject(new Error('cancelled'));
}); });
overlay.addEventListener('click', function (e) { overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); reject(new Error('cancelled')); } if (e.target === overlay) { close(); reject(new Error('cancelled')); }
}); });
document.addEventListener('keydown', onKeydown); document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
close(); reject(new Error('cancelled'));
}
});
submit.addEventListener('click', function () { submit.addEventListener('click', function () {
var v = input.value.trim(); var v = input.value.trim();
var parsed = window.zddc.parseFolder(v); var parsed = window.zddc.parseFolder(v);

View file

@ -44,12 +44,6 @@
} }
// Trigger a download from a same-origin server URL via Content-Disposition. // Trigger a download from a same-origin server URL via Content-Disposition.
// NOTE: an <a download> click is fire-and-forget — a server error
// (401/403/404/5xx) can't be observed here, so failures surface only as
// the browser's own download error, not a toast. This is deliberate: the
// folder path points at zddc-server's streamed virtual "<dir>.zip"
// endpoint, and buffering it through fetch() to make errors catchable
// would defeat the streaming (the archive can be arbitrarily large).
function downloadUrl(filename, url) { function downloadUrl(filename, url) {
var a = document.createElement('a'); var a = document.createElement('a');
a.href = url; a.href = url;
@ -103,12 +97,9 @@
var zip = new window.JSZip(); var zip = new window.JSZip();
for (var i = 0; i < files.length; i++) { for (var i = 0; i < files.length; i++) {
ev.statusInfo('Zipping ' + rootHandle.name + '… (' + (i + 1) + '/' + files.length + ')'); ev.statusInfo('Zipping ' + rootHandle.name + '… (' + (i + 1) + '/' + files.length + ')');
// Hand JSZip the File (a Blob, backed by disk) rather than
// pre-reading every file's arrayBuffer — otherwise the whole
// tree's raw bytes sit in the JS heap at once before zipping.
// JSZip reads each Blob lazily during generateAsync.
var f = await files[i].handle.getFile(); var f = await files[i].handle.getFile();
zip.file(rootHandle.name + '/' + files[i].relPath, f); var buf = await f.arrayBuffer();
zip.file(rootHandle.name + '/' + files[i].relPath, buf);
} }
ev.statusInfo('Generating ' + rootHandle.name + '.zip…'); ev.statusInfo('Generating ' + rootHandle.name + '.zip…');
var blob = await zip.generateAsync({ type: 'blob' }); var blob = await zip.generateAsync({ type: 'blob' });

View file

@ -133,16 +133,6 @@
} catch (_e) { /* private browsing edge cases */ } } catch (_e) { /* private browsing edge cases */ }
} }
// Navigation sequence token. Every async flow that ends by replacing
// the tree root (refresh, rescope, reload, back/forward popstate)
// captures a token before its fetch and bails if a newer navigation
// has started by the time it resolves — otherwise a slow listing can
// land on top of a newer one and leave the tree out of sync with
// state.currentPath / the URL bar.
var navSeq = 0;
function beginNav() { return ++navSeq; }
function isCurrentNav(seq) { return seq === navSeq; }
async function refreshListing() { async function refreshListing() {
// Snapshot expanded paths + selection BEFORE setRoot clears the // Snapshot expanded paths + selection BEFORE setRoot clears the
// tree, then re-apply after the new root is in place. Keeps // tree, then re-apply after the new root is in place. Keeps
@ -151,7 +141,6 @@
// a refresh — including the auto-refresh triggered by the // a refresh — including the auto-refresh triggered by the
// "Show hidden files" toggle. // "Show hidden files" toggle.
var snap = tree.snapshotState(); var snap = tree.snapshotState();
var seq = beginNav();
if (state.source === 'server') { if (state.source === 'server') {
var raw; var raw;
try { try {
@ -160,10 +149,8 @@
statusError('Refresh failed: ' + e.message); statusError('Refresh failed: ' + e.message);
return; return;
} }
if (!isCurrentNav(seq)) return;
tree.setRoot(raw); tree.setRoot(raw);
await tree.restoreState(snap); await tree.restoreState(snap);
if (!isCurrentNav(seq)) return;
tree.render(); tree.render();
statusInfo('Refreshed (' + raw.length + ' item' statusInfo('Refreshed (' + raw.length + ' item'
+ (raw.length === 1 ? '' : 's') + ')'); + (raw.length === 1 ? '' : 's') + ')');
@ -175,10 +162,8 @@
statusError('Refresh failed: ' + e.message); statusError('Refresh failed: ' + e.message);
return; return;
} }
if (!isCurrentNav(seq)) return;
tree.setRoot(raw2); tree.setRoot(raw2);
await tree.restoreState(snap); await tree.restoreState(snap);
if (!isCurrentNav(seq)) return;
tree.render(); tree.render();
statusInfo('Refreshed'); statusInfo('Refreshed');
} }
@ -464,10 +449,7 @@
// selection-only; their preview is "expand to see inside". // selection-only; their preview is "expand to see inside".
if (nextNode && !nextNode.isDir && !nextNode.isZip if (nextNode && !nextNode.isDir && !nextNode.isZip
&& previewModule) { && previewModule) {
// auto:true — keyboard cursor walking the tree. If an previewModule.showFilePreview(nextNode);
// editor has unsaved edits, the preview module leaves it
// in place rather than prompting on every keystroke.
previewModule.showFilePreview(nextNode, { auto: true });
state.lastPreviewedNodeId = nextId; state.lastPreviewedNodeId = nextId;
} }
// Scroll the now-selected row into view. // Scroll the now-selected row into view.
@ -678,7 +660,11 @@
return parentDir; return parentDir;
} }
var escapeHtml = window.app.modules.util.escapeHtml; function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
// Valid party folder name — mirrors zddc.ValidPartyName server-side // Valid party folder name — mirrors zddc.ValidPartyName server-side
// (^[A-Za-z0-9][A-Za-z0-9.-]*$). // (^[A-Za-z0-9][A-Za-z0-9.-]*$).
@ -883,20 +869,14 @@
var loader = window.app.modules.loader; var loader = window.app.modules.loader;
if (!loader) return; if (!loader) return;
if (!dirPath.endsWith('/')) dirPath += '/'; if (!dirPath.endsWith('/')) dirPath += '/';
var seq = beginNav();
// Root-scope reload — refresh the visible top-level listing. // Root-scope reload — refresh the visible top-level listing.
if (dirPath === state.currentPath) { if (dirPath === state.currentPath) {
var es;
try { try {
es = state.source === 'server' var es = state.source === 'server'
? await loader.fetchServerChildren(dirPath) ? await loader.fetchServerChildren(dirPath)
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []); : (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
} catch (e) {
statusError('Reload failed: ' + (e.message || e));
return;
}
if (!isCurrentNav(seq)) return;
tree.setRoot(es); tree.setRoot(es);
} catch (_e) { /* swallow */ }
tree.render(); tree.render();
return; return;
} }
@ -908,18 +888,13 @@
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
}); });
if (hit) { if (hit) {
var raw;
try { try {
raw = state.source === 'server' var raw = state.source === 'server'
? await loader.fetchServerChildren(dirPath) ? await loader.fetchServerChildren(dirPath)
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []); : (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
} catch (e) {
statusError('Reload failed: ' + (e.message || e));
return;
}
if (!isCurrentNav(seq)) return;
tree.setChildren(hit.id, raw); tree.setChildren(hit.id, raw);
hit.expanded = true; hit.expanded = true;
} catch (_e) { /* swallow */ }
tree.render(); tree.render();
} }
} }
@ -1396,7 +1371,8 @@
} }
if (state.source === 'fs') { if (state.source === 'fs') {
if (!node.handle || node.handle.kind !== 'directory') return; if (!node.handle || node.handle.kind !== 'directory') return;
var seq = beginNav(); state.rootHandle = node.handle;
state.currentPath = node.handle.name + '/';
var raw; var raw;
try { try {
raw = await loader.fetchFsChildren(node.handle); raw = await loader.fetchFsChildren(node.handle);
@ -1404,12 +1380,6 @@
statusError('Failed to enter ' + node.name + ': ' + e.message); statusError('Failed to enter ' + node.name + ': ' + e.message);
return; return;
} }
// Mutate scope state only after the fetch succeeds and only if
// we're still the latest navigation — a bail here leaves the
// previous scope intact rather than half-swapped.
if (!isCurrentNav(seq)) return;
state.rootHandle = node.handle;
state.currentPath = node.handle.name + '/';
tree.setRoot(raw); tree.setRoot(raw);
tree.render(); tree.render();
statusInfo('Entered ' + node.name); statusInfo('Entered ' + node.name);
@ -1420,7 +1390,6 @@
// history.pushState, fetches the new directory listing, and // history.pushState, fetches the new directory listing, and
// re-renders the tree from scratch. Page DOES NOT reload. // re-renders the tree from scratch. Page DOES NOT reload.
async function rescopeServer(url, displayName) { async function rescopeServer(url, displayName) {
var seq = beginNav();
var entries; var entries;
try { try {
entries = await loader.fetchServerChildren(url); entries = await loader.fetchServerChildren(url);
@ -1428,10 +1397,6 @@
statusError('Failed to enter ' + displayName + ': ' + (e.message || e)); statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
return; return;
} }
// A newer navigation (another dblclick, a refresh, back/forward)
// started while this listing was in flight — drop this result so we
// don't pushState/setRoot on top of it.
if (!isCurrentNav(seq)) return;
state.currentPath = url; state.currentPath = url;
// Selection / preview belong to the old scope; clear them so // Selection / preview belong to the old scope; clear them so
// the new root doesn't carry stale highlight state. // the new root doesn't carry stale highlight state.
@ -1443,14 +1408,9 @@
tree.setRoot(entries); tree.setRoot(entries);
tree.render(); tree.render();
// Reset the preview pane so the user sees an "empty selection" // Reset the preview pane so the user sees an "empty selection"
// state at the new scope instead of the previous file. Route // state at the new scope instead of the previous file.
// through clearPreview so a live editor is disposed (not leaked).
var pmod = previewMod();
if (pmod && pmod.clearPreview) pmod.clearPreview();
else {
var previewBody = document.getElementById('previewBody'); var previewBody = document.getElementById('previewBody');
if (previewBody) previewBody.innerHTML = ''; if (previewBody) previewBody.innerHTML = '';
}
var previewTitle = document.getElementById('previewTitle'); var previewTitle = document.getElementById('previewTitle');
if (previewTitle) previewTitle.textContent = 'No file selected'; if (previewTitle) previewTitle.textContent = 'No file selected';
var previewMeta = document.getElementById('previewMeta'); var previewMeta = document.getElementById('previewMeta');
@ -1479,16 +1439,6 @@
statusInfo: statusInfo, statusInfo: statusInfo,
statusClear: statusClear, statusClear: statusClear,
showBrowseRoot: showBrowseRoot, showBrowseRoot: showBrowseRoot,
applyResolvedViewMode: applyResolvedViewMode, applyResolvedViewMode: applyResolvedViewMode
// Re-fetch + re-render the current listing (restoring expansion +
// selection). Workflow modules call this after a move/accept so the
// tree reflects the change without a manual reload. upload.js already
// depends on it being present.
refreshListing: refreshListing,
// Shared navigation-sequence token so the popstate handler (app.js)
// can't race the in-tool navigations. beginNav() claims the latest
// token; isCurrentNav(seq) reports whether it's still latest.
beginNav: beginNav,
isCurrentNav: isCurrentNav
}; };
})(); })();

View file

@ -16,7 +16,11 @@
(function () { (function () {
'use strict'; 'use strict';
var escapeHtml = window.app.modules.util.escapeHtml; function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function toast(msg, kind) { function toast(msg, kind) {
if (window.zddc && typeof window.zddc.toast === 'function') { if (window.zddc && typeof window.zddc.toast === 'function') {
@ -36,7 +40,12 @@
return d.toLocaleString(); return d.toLocaleString();
} }
var fmtBytes = window.app.modules.util.fmtSize; function fmtBytes(n) {
if (n == null) return '';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
// Can the principal write (restore) to this file? Mirrors the // Can the principal write (restore) to this file? Mirrors the
// events.js Rename/Delete gating: verbs===undefined means a non-zddc // events.js Rename/Delete gating: verbs===undefined means a non-zddc
@ -337,10 +346,6 @@
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) { if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
return; return;
} }
// The restore itself (the PUT) is the operation that can "fail".
// Keep it in its own try so a later error while refreshing the UI
// can't surface a misleading "Restore failed" after the restore has
// already been persisted.
try { try {
var text = await fetchVersion(node, ent.id); var text = await fetchVersion(node, ent.id);
var resp = await fetch(node.url, { var resp = await fetch(node.url, {
@ -350,22 +355,18 @@
body: text body: text
}); });
if (!resp.ok) throw new Error('HTTP ' + resp.status); if (!resp.ok) throw new Error('HTTP ' + resp.status);
} catch (e) {
toast('Restore failed: ' + (e.message || e), 'error');
return;
}
toast('Restored version from ' + fmtTime(ent.ts), 'success'); toast('Restored version from ' + fmtTime(ent.ts), 'success');
// Best-effort UI refresh — the restore already succeeded, so a // Reflect the new head: refetch the list.
// failure here is logged but never reported as a restore failure.
try {
var entries = await fetchList(node); var entries = await fetchList(node);
renderList(modal, node, entries); renderList(modal, node, entries);
// If the file is open in the preview pane, reload it. // If the file is open in the preview pane, reload it.
var preview = window.app && window.app.modules && window.app.modules.preview; var preview = window.app && window.app.modules && window.app.modules.preview;
if (preview && typeof preview.showFilePreview === 'function') { if (preview && typeof preview.showFilePreview === 'function') {
preview.showFilePreview(node); try { preview.showFilePreview(node); } catch (_e) { /* best effort */ }
}
} catch (e) {
toast('Restore failed: ' + (e.message || e), 'error');
} }
} catch (_e) { /* refresh is best-effort; restore is done */ }
} }
// ── Entry point ───────────────────────────────────────────────────── // ── Entry point ─────────────────────────────────────────────────────

View file

@ -58,10 +58,22 @@
currentRow = null; currentRow = null;
} }
// ── Formatting ── // ── Formatting (kept local so this module is self-contained) ──
var escapeHtml = window.app.modules.util.escapeHtml; function escapeHtml(s) {
var fmtSize = window.app.modules.util.fmtSize; return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function fmtDate(d) { function fmtDate(d) {
if (!d) return ''; if (!d) return '';

View file

@ -69,19 +69,8 @@
// scopeDropTarget: cascade's drop_target at currentPath // scopeDropTarget: cascade's drop_target at currentPath
// scopeDefaultTool: cascade's default_tool at currentPath // scopeDefaultTool: cascade's default_tool at currentPath
// (empty when no default declared) // (empty when no default declared)
// scopeCanonicalFolder: cascade's canonical-folder slot
// ('incoming'|'received'|'working'|'staging'|…),
// drives scope-aware menu items
// scopeOnPlanReview: cascade above has an on_plan_review block
// All refreshed by loader.js from response headers on each fetch.
scopeDropTarget: false, scopeDropTarget: false,
scopeDefaultTool: '', scopeDefaultTool: '',
scopeCanonicalFolder: '',
scopeOnPlanReview: false,
// Whether the listing includes dotfiles. Toggled by the
// "Show hidden files" menu item; URL-persisted via ?hidden=1.
showHidden: false,
// Autofilter — when non-empty, the tree hides files that // Autofilter — when non-empty, the tree hides files that
// don't match and folders whose subtree has no matches. // don't match and folders whose subtree has no matches.

View file

@ -11,12 +11,10 @@
var state = window.app.state; var state = window.app.state;
// Lowercased extension (no leading dot), '' for dotfiles / no-ext /
// trailing-dot names. Delegates to the shared parser so the rule
// stays in one place (CLAUDE.md: all extension handling goes through
// window.zddc).
function splitExt(name) { function splitExt(name) {
return window.zddc.splitExtension(name).extension; 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. // Build a raw entry from the server's FileInfo shape.
@ -224,6 +222,7 @@
fetchServerChildren: fetchServerChildren, fetchServerChildren: fetchServerChildren,
fetchFsChildren: fetchFsChildren, fetchFsChildren: fetchFsChildren,
autoDetectServerMode: autoDetectServerMode, autoDetectServerMode: autoDetectServerMode,
splitExt: splitExt,
ensureJSZip: ensureJSZip ensureJSZip: ensureJSZip
}; };
})(); })();

View file

@ -45,18 +45,44 @@
} }
} }
var util = window.app.modules.util; // Compute today + N days as a YYYY-MM-DD string.
var isoDatePlus = util.isoDatePlus; function isoDatePlus(days) {
var d = new Date();
d.setDate(d.getDate() + days);
var y = d.getFullYear();
var m = ('0' + (d.getMonth() + 1)).slice(-2);
var dd = ('0' + d.getDate()).slice(-2);
return y + '-' + m + '-' + dd;
}
// Fetch suggestion emails from /.profile/access so the originator // Fetch suggestion emails from /.profile/access so the originator
// field has a datalist of likely values. Best-effort — silent on // field has a datalist of likely values. Best-effort — silent on
// failure (the field still accepts free text). // failure (the field still accepts free text).
var fetchOriginatorSuggestions = util.fetchAccessEmails; async function fetchOriginatorSuggestions() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!resp.ok) return [];
var data = await resp.json();
var out = [];
// The endpoint exposes the current user + any role members
// visible to them. Pull anything that looks like an email
// for the datalist; the field is otherwise free text.
if (data && data.email) out.push(data.email);
return out;
} catch (_e) {
return [];
}
}
// Build the YAML body for the plan-review POST. Quoting is minimal // Build the YAML body for the plan-review POST. Quoting is minimal
// (just enough for emails with special chars). // (just enough for emails with special chars).
function buildBody(values) { function buildBody(values) {
var yamlString = util.yamlQuote; function yamlString(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
return [ return [
'review_lead: ' + yamlString(values.reviewLead), 'review_lead: ' + yamlString(values.reviewLead),
'approver: ' + yamlString(values.approver), 'approver: ' + yamlString(values.approver),
@ -119,14 +145,7 @@
}); });
}); });
// Escape handler bound once, removed in close() — every
// dismissal path routes through close() so the document
// listener never outlives the modal.
function onKeydown(e) {
if (e.key === 'Escape') { close(); reject(new Error('cancelled')); }
}
function close() { function close() {
document.removeEventListener('keydown', onKeydown);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay); if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
} }
@ -140,7 +159,13 @@
reject(new Error('cancelled')); reject(new Error('cancelled'));
} }
}); });
document.addEventListener('keydown', onKeydown); document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
close();
reject(new Error('cancelled'));
}
});
box.querySelector('#pr-submit').addEventListener('click', function () { box.querySelector('#pr-submit').addEventListener('click', function () {
var values = { var values = {
@ -162,7 +187,14 @@
}); });
} }
var escapeHtml = util.escapeHtml; function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
// Detect whether a tree node is an archive/<party>/received/<tracking>/ // Detect whether a tree node is an archive/<party>/received/<tracking>/
// folder. The path is path-shaped, not content-based — tracking-number // folder. The path is path-shaped, not content-based — tracking-number
@ -179,11 +211,8 @@
&& parts[3].toLowerCase() === 'received'; && parts[3].toLowerCase() === 'received';
} }
var busy = false;
// Run the Plan Review flow: open the modal, POST the result. // Run the Plan Review flow: open the modal, POST the result.
async function invoke(node) { async function invoke(node) {
if (busy) return;
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return; if (!tree) return;
var url = tree.pathFor(node); var url = tree.pathFor(node);
@ -198,8 +227,6 @@
return; // cancelled return; // cancelled
} }
busy = true;
try {
statusInfo('Plan Review — submitting…'); statusInfo('Plan Review — submitting…');
var body = buildBody(values); var body = buildBody(values);
var resp; var resp;
@ -238,9 +265,6 @@
} else { } else {
statusInfo('Plan Review complete.'); statusInfo('Plan Review complete.');
} }
} finally {
busy = false;
}
} }
window.app.modules.planReview = { window.app.modules.planReview = {

View file

@ -42,39 +42,34 @@
var SIDEBAR_DEFAULT_WIDTH = 280; var SIDEBAR_DEFAULT_WIDTH = 280;
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
var util = window.app.modules.util; function escapeHtml(s) {
var escapeHtml = util.escapeHtml; return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
var hashContent = util.hashContent; .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl } var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
var lastFmHeight = FM_DEFAULT_HEIGHT; var lastFmHeight = FM_DEFAULT_HEIGHT;
function dispose() { async function hashContent(text) {
if (currentInstance) { if (!window.crypto || !window.crypto.subtle) return null;
// Tear down the document-level resizer drag listeners (added var enc = new TextEncoder().encode(text);
// lazily on mousedown). They're normally removed on mouseup, var buf = await window.crypto.subtle.digest('SHA-256', enc);
// but a dispose mid-drag — or any switch away — would otherwise var bytes = new Uint8Array(buf);
// strand them pointing at the dead shell. The AbortController var hex = '';
// removes whatever is still attached in one call. for (var i = 0; i < bytes.length; i++) {
if (currentInstance.ac) { hex += bytes[i].toString(16).padStart(2, '0');
try { currentInstance.ac.abort(); } catch (_) { /* ignore */ }
} }
if (currentInstance.editor) { return hex;
}
function dispose() {
if (currentInstance && currentInstance.editor) {
try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ } try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ }
} }
}
currentInstance = null; currentInstance = null;
} }
function isDirty() {
return !!(currentInstance && currentInstance.dirty);
}
function currentNode() {
return currentInstance ? currentInstance.node : null;
}
// ── Front matter ──────────────────────────────────────────────────────── // ── Front matter ────────────────────────────────────────────────────────
// Lightweight YAML front-matter parser. Same envelope as mdedit's: // Lightweight YAML front-matter parser. Same envelope as mdedit's:
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays. // `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
@ -278,11 +273,38 @@
// ── Save ──────────────────────────────────────────────────────────────── // ── Save ────────────────────────────────────────────────────────────────
function saveContent(node, content) { async function saveContent(node, content) {
return util.saveFile(node, content, 'text/markdown; charset=utf-8'); if (node.handle && typeof node.handle.createWritable === 'function') {
// Local folders are picked read-only; escalate to readwrite on
// first save (one FS-Access prompt, then granted for the session).
var up = window.app.modules.upload;
if (up && up.ensureWritable) await up.ensureWritable();
var writable = await node.handle.createWritable();
await writable.write(content);
await writable.close();
return;
}
if (node.url && window.app.state.source === 'server') {
var resp = await fetch(node.url, {
method: 'PUT',
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
body: content,
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
throw new Error('No write target for this file (read-only source).');
} }
var isZipMemberNode = util.isZipMemberNode; // A markdown file living inside a .zip is read-only: a ZipFileHandle
// refuses createWritable (offline / nested), and zddc-server refuses
// writes to a "<…>.zip/<member>" URL (405).
function isZipMemberNode(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && window.app.state.source === 'server' && /\.zip\//i.test(node.url)) return true;
return false;
}
function canSave(node) { function canSave(node) {
if (isZipMemberNode(node)) return false; if (isZipMemberNode(node)) return false;
@ -542,20 +564,15 @@
})); }));
} }
// One AbortController per mount — wired into the document-level currentInstance = {
// resizer listeners below so dispose() can detach them all at once.
var ac = new AbortController();
var instance = {
editor: editor, editor: editor,
container: container, container: container,
dirty: false, dirty: false,
node: node, node: node,
hash: initialHash, hash: initialHash,
tocEl: tocBody, tocEl: tocBody,
fmEl: fmTextarea, fmEl: fmTextarea
ac: ac
}; };
currentInstance = instance;
if (!writableMode) { if (!writableMode) {
saveBtn.disabled = true; saveBtn.disabled = true;
@ -592,8 +609,8 @@
resizer.classList.add('is-dragging'); resizer.classList.add('is-dragging');
startX = e.clientX; startX = e.clientX;
startW = sidebar.getBoundingClientRect().width; startW = sidebar.getBoundingClientRect().width;
document.addEventListener('mousemove', onMove, { signal: ac.signal }); document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp, { signal: ac.signal }); document.addEventListener('mouseup', onUp);
e.preventDefault(); e.preventDefault();
}); });
resizer.addEventListener('keydown', function (e) { resizer.addEventListener('keydown', function (e) {
@ -637,8 +654,8 @@
fmResizer.classList.add('is-dragging'); fmResizer.classList.add('is-dragging');
startY = e.clientY; startY = e.clientY;
startH = fmSection.getBoundingClientRect().height; startH = fmSection.getBoundingClientRect().height;
document.addEventListener('mousemove', onMove, { signal: ac.signal }); document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp, { signal: ac.signal }); document.addEventListener('mouseup', onUp);
e.preventDefault(); e.preventDefault();
}); });
fmResizer.addEventListener('keydown', function (e) { fmResizer.addEventListener('keydown', function (e) {
@ -653,8 +670,7 @@
// ── Change tracking + auto-rerender ──────────────────────────────── // ── Change tracking + auto-rerender ────────────────────────────────
function markDirty(isDirty) { function markDirty(isDirty) {
if (currentInstance !== instance) return; // editor replaced currentInstance.dirty = isDirty;
instance.dirty = isDirty;
// Re-read canSave at every transition, not via a closure-captured // Re-read canSave at every transition, not via a closure-captured
// value, so the gate reflects current write authority — see the // value, so the gate reflects current write authority — see the
// matching pattern in preview-yaml.js. // matching pattern in preview-yaml.js.
@ -662,40 +678,29 @@
dirtyEl.textContent = isDirty ? '● modified' : ''; dirtyEl.textContent = isDirty ? '● modified' : '';
} }
// The debounced handlers can resolve AFTER this editor was disposed
// and a new file mounted (the timer + the await both outlive the
// switch). Bail when we're no longer the active instance so we never
// call into a destroyed Toast UI editor or write the wrong file's
// dirty/hash state.
var onChange = debounce(async function () { var onChange = debounce(async function () {
if (currentInstance !== instance) return;
var body = editor.getMarkdown(); var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body)); var h = await hashContent(assembleContent(fmTextarea.value, body));
if (currentInstance !== instance) return; markDirty(h !== currentInstance.hash);
markDirty(h !== instance.hash);
renderToc(tocBody, body, editor); renderToc(tocBody, body, editor);
}, 250); }, 250);
editor.on('change', onChange); editor.on('change', onChange);
var onFmChange = debounce(async function () { var onFmChange = debounce(async function () {
if (currentInstance !== instance) return;
var body = editor.getMarkdown(); var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body)); var h = await hashContent(assembleContent(fmTextarea.value, body));
if (currentInstance !== instance) return; markDirty(h !== currentInstance.hash);
markDirty(h !== instance.hash);
}, 250); }, 250);
fmTextarea.addEventListener('input', onFmChange); fmTextarea.addEventListener('input', onFmChange);
// ── Save ─────────────────────────────────────────────────────────── // ── Save ───────────────────────────────────────────────────────────
async function save() { async function save() {
if (currentInstance !== instance) return; if (!currentInstance.dirty || !canSave(node)) return;
if (!instance.dirty || !canSave(node)) return;
var content = assembleContent(fmTextarea.value, editor.getMarkdown()); var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try { try {
statusEl.textContent = 'Saving…'; statusEl.textContent = 'Saving…';
await saveContent(node, content); await saveContent(node, content);
if (currentInstance !== instance) return; // switched away mid-save currentInstance.hash = await hashContent(content);
instance.hash = await hashContent(content);
markDirty(false); markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) { if (window.zddc && window.zddc.toast) {
@ -727,7 +732,7 @@
convertBtns.forEach(function (a) { convertBtns.forEach(function (a) {
a.addEventListener('click', async function (e) { a.addEventListener('click', async function (e) {
var fmt = a.dataset.fmt; var fmt = a.dataset.fmt;
if (!instance.dirty) { if (!currentInstance.dirty) {
// Clean — let the browser handle the click. The // Clean — let the browser handle the click. The
// server's response (DOCX/HTML/PDF bytes, 422, // server's response (DOCX/HTML/PDF bytes, 422,
// 503, etc.) lands in whatever target the user // 503, etc.) lands in whatever target the user
@ -746,7 +751,7 @@
} }
statusEl.textContent = 'Saving before download…'; statusEl.textContent = 'Saving before download…';
try { await save(); } catch (_) { /* save() surfaces its own error */ } try { await save(); } catch (_) { /* save() surfaces its own error */ }
if (currentInstance !== instance || instance.dirty) return; // save failed / switched away if (currentInstance.dirty) return; // save failed; toast already shown
statusEl.textContent = 'Downloading ' + fmt.toUpperCase() + '…'; statusEl.textContent = 'Downloading ' + fmt.toUpperCase() + '…';
// Re-trigger the click. dirty=false now so the handler // Re-trigger the click. dirty=false now so the handler
// exits early on the second pass and the browser // exits early on the second pass and the browser
@ -758,8 +763,6 @@
window.app.modules.markdown = { window.app.modules.markdown = {
render: render, render: render,
dispose: dispose, dispose: dispose
isDirty: isDirty,
currentNode: currentNode
}; };
})(); })();

View file

@ -22,8 +22,10 @@
if (!window.app || !window.app.modules) return; if (!window.app || !window.app.modules) return;
var util = window.app.modules.util; function escapeHtml(s) {
var escapeHtml = util.escapeHtml; return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Filename routing ──────────────────────────────────────────────────── // ── Filename routing ────────────────────────────────────────────────────
@ -45,14 +47,32 @@
// ── Save (mirrors preview-markdown.js) ───────────────────────────────── // ── Save (mirrors preview-markdown.js) ─────────────────────────────────
function saveContent(node, content) { async function saveContent(node, content) {
// Via the shared saveFile so local (FS-Access) saves escalate to if (node.handle && typeof node.handle.createWritable === 'function') {
// readwrite the same as the markdown editor — previously this path var writable = await node.handle.createWritable();
// skipped ensureWritable and failed on read-only-picked folders. await writable.write(content);
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8'); await writable.close();
return;
}
if (node.url && window.app.state.source === 'server') {
var resp = await fetch(node.url, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-yaml; charset=utf-8' },
body: content,
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
throw new Error('No write target for this file (read-only source).');
} }
var isZipMemberNode = util.isZipMemberNode; function isZipMemberNode(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && window.app.state.source === 'server'
&& /\.zip\//i.test(node.url)) return true;
return false;
}
function canSave(node) { function canSave(node) {
if (isZipMemberNode(node)) return false; if (isZipMemberNode(node)) return false;
@ -76,7 +96,17 @@
return false; return false;
} }
var hashContent = util.hashContent; async function hashContent(text) {
if (!window.crypto || !window.crypto.subtle) return null;
var enc = new TextEncoder().encode(text);
var buf = await window.crypto.subtle.digest('SHA-256', enc);
var bytes = new Uint8Array(buf);
var hex = '';
for (var i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return hex;
}
// ── .zddc schema ──────────────────────────────────────────────────────── // ── .zddc schema ────────────────────────────────────────────────────────
// //
@ -348,24 +378,12 @@
// ── Mount ─────────────────────────────────────────────────────────────── // ── Mount ───────────────────────────────────────────────────────────────
var currentEditor = null; var currentEditor = null;
var currentDirty = false;
var currentNodeRef = null;
function dispose() { function dispose() {
// CM doesn't have an explicit destroy(); GC handles it once // CM doesn't have an explicit destroy(); GC handles it once
// the host element is removed. Clear our reference so a stale // the host element is removed. Clear our reference so a stale
// editor doesn't keep handlers alive. // editor doesn't keep handlers alive.
currentEditor = null; currentEditor = null;
currentDirty = false;
currentNodeRef = null;
}
function isDirty() {
return currentDirty;
}
function currentNode() {
return currentNodeRef;
} }
async function render(node, container, ctx) { async function render(node, container, ctx) {
@ -481,8 +499,6 @@
// Force an initial lint pass now that _zddcNode is set. // Force an initial lint pass now that _zddcNode is set.
editor.performLint(); editor.performLint();
currentEditor = editor; currentEditor = editor;
currentNodeRef = node;
currentDirty = false;
if (!writable) { if (!writable) {
saveBtn.disabled = true; saveBtn.disabled = true;
@ -498,16 +514,12 @@
var initialHash = await hashContent(text); var initialHash = await hashContent(text);
function markDirty(isDirty) { function markDirty(isDirty) {
if (currentEditor !== editor) return; // editor replaced
currentDirty = isDirty;
saveBtn.disabled = !isDirty || !canSave(node); saveBtn.disabled = !isDirty || !canSave(node);
dirtyEl.textContent = isDirty ? '● modified' : ''; dirtyEl.textContent = isDirty ? '● modified' : '';
} }
editor.on('change', async function () { editor.on('change', async function () {
if (currentEditor !== editor) return; // switched away
var h = await hashContent(editor.getValue()); var h = await hashContent(editor.getValue());
if (currentEditor !== editor) return; // replaced during await
markDirty(h !== initialHash); markDirty(h !== initialHash);
}); });
@ -552,9 +564,6 @@
window.app.modules.yamledit = { window.app.modules.yamledit = {
handles: handles, handles: handles,
render: render, render: render
dispose: dispose,
isDirty: isDirty,
currentNode: currentNode
}; };
})(); })();

View file

@ -19,8 +19,10 @@
console.error('[browse] zddc.preview not loaded — preview disabled.'); console.error('[browse] zddc.preview not loaded — preview disabled.');
} }
var util = window.app.modules.util; function escapeHtml(s) {
var escapeHtml = util.escapeHtml; return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var MIME = { var MIME = {
'pdf': 'application/pdf', 'pdf': 'application/pdf',
@ -39,7 +41,13 @@
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; } function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
var fmtSize = util.fmtSize; function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
async function getArrayBuffer(node) { async function getArrayBuffer(node) {
// A zip member node carries a ZipFileHandle in node.handle, so // A zip member node carries a ZipFileHandle in node.handle, so
@ -68,62 +76,8 @@
return { url: URL.createObjectURL(blob), fromServer: false }; return { url: URL.createObjectURL(blob), fromServer: false };
} }
// ── Editor lifecycle helpers ─────────────────────────────────────────────
// The markdown and YAML plugins each mount a long-lived editor into the
// preview pane. Switching files (or clearing the pane) must dispose the
// live editor first — otherwise the Toast UI instance, its DOM, and its
// document-level resizer listeners leak when we overwrite the container.
function editorModules() {
var m = window.app.modules;
return [m.markdown, m.yamledit].filter(Boolean);
}
function disposeEditors() {
editorModules().forEach(function (mod) {
if (mod.dispose) { try { mod.dispose(); } catch (_) { /* ignore */ } }
});
}
// The editor module (if any) holding unsaved edits, else null.
function dirtyEditor() {
var mods = editorModules();
for (var i = 0; i < mods.length; i++) {
if (mods[i].isDirty && mods[i].isDirty()) return mods[i];
}
return null;
}
function samePreviewNode(a, b) {
if (!a || !b) return false;
if (a === b) return true;
if (a.url && b.url) return a.url === b.url;
return a.name === b.name && a.parentId === b.parentId;
}
// Tear down any live editor and blank the pane. Used by callers that
// reset the preview directly (rescope, popstate) so they don't leak the
// editor or strand its dirty state.
function clearPreview() {
disposeEditors();
var container = document.getElementById('previewBody');
if (container) container.innerHTML = '';
}
// Warn before a full page unload (reload / close / external nav) drops
// unsaved editor changes. SPA-internal switches are guarded in
// renderInline; this catches the browser-level exit.
window.addEventListener('beforeunload', function (e) {
if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; }
});
// ── Inline rendering ──────────────────────────────────────────────────── // ── Inline rendering ────────────────────────────────────────────────────
// Bumped on every renderInline entry; a render that loses the race
// (a newer selection started while its bytes were in flight) bails
// before writing stale content into the shared pane.
var renderSeq = 0;
function renderEmpty(container, msg) { function renderEmpty(container, msg) {
container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>'; container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>';
} }
@ -133,37 +87,13 @@
+ escapeHtml(msg) + '</div>'; + escapeHtml(msg) + '</div>';
} }
async function renderInline(node, opts) { async function renderInline(node) {
opts = opts || {};
var container = document.getElementById('previewBody'); var container = document.getElementById('previewBody');
var titleEl = document.getElementById('previewTitle'); var titleEl = document.getElementById('previewTitle');
var metaEl = document.getElementById('previewMeta'); var metaEl = document.getElementById('previewMeta');
var popoutBtn = document.getElementById('previewPopout'); var popoutBtn = document.getElementById('previewPopout');
if (!container) return; if (!container) return;
// Guard unsaved editor edits before we tear the editor down.
var dm = dirtyEditor();
if (dm) {
var cur = dm.currentNode ? dm.currentNode() : null;
if (samePreviewNode(cur, node)) {
// Re-selecting the file we're already editing — don't reload
// and clobber the in-progress edits.
return;
}
if (opts.auto) {
// Keyboard/auto preview (cursor walking the tree): leave the
// dirty editor in place rather than prompting on every key.
return;
}
var label = cur ? cur.name : 'this file';
if (!window.confirm('Discard unsaved changes to ' + label + '?')) return;
}
// Safe to replace the pane now: dispose any live editor so its
// instance + document-level listeners don't leak.
disposeEditors();
var seq = ++renderSeq;
if (titleEl) titleEl.textContent = node.name; if (titleEl) titleEl.textContent = node.name;
if (metaEl) { if (metaEl) {
var meta = []; var meta = [];
@ -204,7 +134,6 @@
if (ext === 'pdf' || ext === 'html' || ext === 'htm') { if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
try { try {
var info = await getBlobUrl(node); var info = await getBlobUrl(node);
if (seq !== renderSeq) return;
var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"'; var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"';
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>'; container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
} catch (e) { } catch (e) {
@ -217,7 +146,6 @@
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) { if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
try { try {
var imgInfo = await getBlobUrl(node); var imgInfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name) container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name)
+ '" src="' + escapeHtml(imgInfo.url) + '">'; + '" src="' + escapeHtml(imgInfo.url) + '">';
} catch (e) { } catch (e) {
@ -229,7 +157,6 @@
if (preview && preview.isTiff(ext)) { if (preview && preview.isTiff(ext)) {
try { try {
var tiffBuf = await getArrayBuffer(node); var tiffBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = ''; container.innerHTML = '';
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name }); await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
} catch (e) { } catch (e) {
@ -241,7 +168,6 @@
if (preview && preview.isZip(ext)) { if (preview && preview.isZip(ext)) {
try { try {
var zipBuf = await getArrayBuffer(node); var zipBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = ''; container.innerHTML = '';
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name }); await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
} catch (e) { } catch (e) {
@ -256,7 +182,6 @@
if (preview && preview.isOffice(ext)) { if (preview && preview.isOffice(ext)) {
try { try {
var officeBuf = await getArrayBuffer(node); var officeBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = ''; container.innerHTML = '';
if (ext === 'docx') { if (ext === 'docx') {
await preview.renderDocx(document, container, officeBuf, { fileName: node.name }); await preview.renderDocx(document, container, officeBuf, { fileName: node.name });
@ -272,7 +197,6 @@
if (preview && preview.isText(ext)) { if (preview && preview.isText(ext)) {
try { try {
var txtBuf = await getArrayBuffer(node); var txtBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf); var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
var MAX = 200000; var MAX = 200000;
if (text.length > MAX) { if (text.length > MAX) {
@ -293,7 +217,6 @@
// Unknown type — offer a download link. // Unknown type — offer a download link.
try { try {
var fallbackInfo = await getBlobUrl(node); var fallbackInfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML = container.innerHTML =
'<div class="preview-empty">' '<div class="preview-empty">'
+ 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. ' + 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. '
@ -435,13 +358,11 @@
if (node.isDir) return; if (node.isDir) return;
opts = opts || {}; opts = opts || {};
if (opts.popup) return renderInPopup(node); if (opts.popup) return renderInPopup(node);
return renderInline(node, opts); return renderInline(node);
} }
window.app.modules.preview = { window.app.modules.preview = {
showFilePreview: showFilePreview, showFilePreview: showFilePreview,
// Tear down any live editor + blank the pane (rescope / popstate).
clearPreview: clearPreview,
// Expose for the markdown plugin so it can read file bytes. // Expose for the markdown plugin so it can read file bytes.
getArrayBuffer: getArrayBuffer getArrayBuffer: getArrayBuffer
}; };

View file

@ -25,16 +25,11 @@
var t = window.zddc && window.zddc.toast; var t = window.zddc && window.zddc.toast;
if (t) t(msg, level || 'info'); if (t) t(msg, level || 'info');
} }
// Re-fetch the current listing so the moved file appears/disappears function escapeHtml(s) {
// without a manual reload. Best-effort: absent on older builds. return String(s).replace(/[&<>"']/g, function (c) {
function refreshListing() { return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c];
var ev = window.app.modules.events; });
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
} }
// Guard against a second invocation while a move is mid-flight (e.g. a
// double menu click). The picker modal also blocks re-entry while open.
var busy = false;
var escapeHtml = window.app.modules.util.escapeHtml;
// ── Scope detection: path-shape, not cascade-content ────────────── // ── Scope detection: path-shape, not cascade-content ──────────────
// A file is stageable if its path matches // A file is stageable if its path matches
@ -95,6 +90,18 @@
.map(function (e) { return e.name; }); .map(function (e) { return e.name; });
} }
async function fetchSelfEmail() {
try {
var r = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!r.ok) return '';
var d = await r.json();
return (d && d.email) || '';
} catch (_e) { return ''; }
}
// POST X-ZDDC-Op: mkdir to create a new directory. Idempotent. // POST X-ZDDC-Op: mkdir to create a new directory. Idempotent.
async function mkdir(absUrl) { async function mkdir(absUrl) {
var resp = await fetch(absUrl, { var resp = await fetch(absUrl, {
@ -260,7 +267,6 @@
// ── Action drivers ───────────────────────────────────────────────── // ── Action drivers ─────────────────────────────────────────────────
async function invokeStage(node) { async function invokeStage(node) {
if (busy) return;
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return; if (!tree) return;
var srcUrl = tree.pathFor(node); var srcUrl = tree.pathFor(node);
@ -283,18 +289,9 @@
choice = await openStagePicker({ fileCount: 1, folders: folders }); choice = await openStagePicker({ fileCount: 1, folders: folders });
} catch (_e) { return; } } catch (_e) { return; }
busy = true;
try {
// Stage is a non-atomic mkdir-then-move (no single composite op).
// Track whether the folder was freshly created so that, if the
// move then fails, we can tell the user the folder exists but the
// file didn't make it — otherwise an empty folder appears with a
// generic "move failed" and no explanation.
var createdFolder = false;
if (choice.create) { if (choice.create) {
try { try {
await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/'); await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/');
createdFolder = true;
} catch (e) { } catch (e) {
status((e && e.message) || 'mkdir failed', 'error'); status((e && e.message) || 'mkdir failed', 'error');
return; return;
@ -305,24 +302,13 @@
try { try {
await moveFile(srcUrl, dstUrl); await moveFile(srcUrl, dstUrl);
} catch (e) { } catch (e) {
var msg = (e && e.message) || 'move failed'; status((e && e.message) || 'move failed', 'error');
if (createdFolder) {
msg += ' — the new folder "' + choice.folderName
+ '" was created but ' + node.name + ' was not moved into it.';
}
status(msg, 'error');
refreshListing(); // surface the (possibly empty) new folder
return; return;
} }
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/', 'success'); status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success');
refreshListing();
} finally {
busy = false;
}
} }
async function invokeUnstage(node) { async function invokeUnstage(node) {
if (busy) return;
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return; if (!tree) return;
var srcUrl = tree.pathFor(node); var srcUrl = tree.pathFor(node);
@ -340,19 +326,13 @@
var target = choice.target; var target = choice.target;
if (!target.endsWith('/')) target += '/'; if (!target.endsWith('/')) target += '/';
var dstUrl = target + encodeURIComponent(node.name); var dstUrl = target + encodeURIComponent(node.name);
busy = true;
try {
try { try {
await moveFile(srcUrl, dstUrl); await moveFile(srcUrl, dstUrl);
} catch (e) { } catch (e) {
status((e && e.message) || 'move failed', 'error'); status((e && e.message) || 'move failed', 'error');
return; return;
} }
status('Unstaged ' + node.name + ' → ' + target, 'success'); status('Unstaged ' + node.name + ' → ' + target + ' — reload to see the move.', 'success');
refreshListing();
} finally {
busy = false;
}
} }
window.app.modules.stage = { window.app.modules.stage = {

View file

@ -211,7 +211,13 @@
// ── Rendering ──────────────────────────────────────────────────────── // ── Rendering ────────────────────────────────────────────────────────
var fmtSize = window.app.modules.util.fmtSize; function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function fmtDate(d) { function fmtDate(d) {
if (!d) return ''; if (!d) return '';
@ -220,7 +226,10 @@
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
} }
var escapeHtml = window.app.modules.util.escapeHtml; function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Per-extension icon map → Lucide outline-icon sprite ids. The // Per-extension icon map → Lucide outline-icon sprite ids. The
// actual SVG markup is produced by window.zddc.icons.html(id), // actual SVG markup is produced by window.zddc.icons.html(id),
@ -460,6 +469,10 @@
el.innerHTML = html; el.innerHTML = html;
} }
// Sort headers no longer exist in the DOM (the tree replaced the
// table); the tree.setSort() method still works but only via
// programmatic callers — there's no UI for changing sort yet.
// True when this .zip node lives inside another zip, so its bytes // True when this .zip node lives inside another zip, so its bytes
// can't be fetched as a standalone server resource: we read them // can't be fetched as a standalone server resource: we read them
// through the containing handle (offline / nested) or by fetching // through the containing handle (offline / nested) or by fetching
@ -508,14 +521,7 @@
// it as a directory handle; members // it as a directory handle; members
// become ordinary dir/file nodes // become ordinary dir/file nodes
async function loadChildren(node) { async function loadChildren(node) {
if (node.loaded || node.loading) return; if (node.loaded) return;
// In-flight guard: a folder can be (re)toggled while its first
// load is still pending — rapid Enter/ArrowRight key-repeat, or a
// double-click landing during a single-click's load. Without this,
// both calls pass the !loaded check and fire duplicate fetches that
// race in setChildren. The flag serializes per-node so the second
// caller is a no-op until the first resolves.
node.loading = true;
try { try {
if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) { if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/')); setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
@ -535,8 +541,6 @@
} catch (e) { } catch (e) {
window.app.modules.events.statusError( window.app.modules.events.statusError(
'Failed to load ' + node.name + ': ' + e.message); 'Failed to load ' + node.name + ': ' + e.message);
} finally {
node.loading = false;
} }
} }
@ -686,6 +690,15 @@
loadChildren: loadChildren, loadChildren: loadChildren,
snapshotState: snapshotState, snapshotState: snapshotState,
restoreState: restoreState, restoreState: restoreState,
setSort: function (key) {
if (state.sort.key === key) {
state.sort.dir = -state.sort.dir;
} else {
state.sort.key = key;
state.sort.dir = 1;
}
render();
},
// Set both key and direction explicitly. dir: 1 (asc) or -1 (desc). // Set both key and direction explicitly. dir: 1 (asc) or -1 (desc).
// Used by the toolbar's sort dropdown. // Used by the toolbar's sort dropdown.
setSortExplicit: function (key, dir) { setSortExplicit: function (key, dir) {

View file

@ -286,11 +286,20 @@
if (lastResolved) msg += ' — last at ' + lastResolved; if (lastResolved) msg += ' — last at ' + lastResolved;
note(msg, 'success'); note(msg, 'success');
} }
// Reload the current listing so the new +Cn file appears in the // Reload the listing of the workflow folder so the new +Cn file
// tree. Best-effort. // appears in the tree. The workflow folder is the parent of the
// virtual `received/` (i.e., the URL with one `/received/<file>`
// suffix stripped).
var refreshUrl = targetURL.replace(/\/received\/[^/]+\/?$/, '/');
try { try {
var ev = window.app.modules.events; var ev = window.app.modules.events;
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing(); if (ev && typeof ev.refreshListing === 'function') {
ev.refreshListing();
} else if (refreshUrl) {
// Best-effort fallback: re-navigate to the workflow folder
// so its listing is refreshed.
// (No action — refreshListing absence implies older browse.)
}
} catch (_e) { /* refresh is best-effort */ } } catch (_e) { /* refresh is best-effort */ }
} }

View file

@ -1,131 +0,0 @@
// util.js — small browse-local helpers shared across the tool's modules.
//
// Consolidates copies that had drifted across modules: escapeHtml (some
// variants escaped single-quotes and handled null, others didn't), the
// SHA-256 content hasher (byte-identical in both editors), ISO-date and
// YAML-quote helpers (duplicated across the workflow modals), the
// /.profile/access email lookup, byte-size formatting, and the editor
// save/zip-member primitives. Attaches to window.app.modules.util — no new
// global (per the two-globals rule). Concatenated right after init.js so
// it's present when every later module's IIFE runs.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
// Escape a value for HTML text/attribute insertion. Escapes all five
// significant characters (including the single quote, which some call
// sites need for single-quoted attributes) and treats null/undefined
// as an empty string. Strict superset of every previous local copy.
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
// SHA-256 hex of a string, or null when WebCrypto is unavailable.
// Used to gate editor dirty-state.
async function hashContent(text) {
if (!window.crypto || !window.crypto.subtle) return null;
var enc = new TextEncoder().encode(text);
var buf = await window.crypto.subtle.digest('SHA-256', enc);
var bytes = new Uint8Array(buf);
var hex = '';
for (var i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return hex;
}
function pad2(n) { return ('0' + n).slice(-2); }
function fmtIsoDate(d) {
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
// YYYY-MM-DD for today / today + N days (local time).
function isoDateToday() { return fmtIsoDate(new Date()); }
function isoDatePlus(days) {
var d = new Date();
d.setDate(d.getDate() + days);
return fmtIsoDate(d);
}
// Double-quoted YAML scalar with backslash + quote escaping. Enough for
// the email/string fields the workflow modals emit.
function yamlQuote(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
// GET /.profile/access → [email] for datalist suggestions. Best-effort:
// returns [] on any error so callers can populate a datalist blind.
async function fetchAccessEmails() {
try {
var r = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!r.ok) return [];
var d = await r.json();
return (d && d.email) ? [d.email] : [];
} catch (_e) { return []; }
}
function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
// A file living inside a .zip is read-only: a ZipFileHandle refuses
// createWritable (offline / nested) and zddc-server refuses writes to a
// "<…>.zip/<member>" URL (405).
function isZipMemberNode(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && window.app.state.source === 'server'
&& /\.zip\//i.test(node.url)) return true;
return false;
}
// Write content back to a file's source. Local (FS-Access) folders are
// picked read-only, so the first write escalates to readwrite via
// upload.ensureWritable (one permission prompt, then granted for the
// session). contentType sets the PUT Content-Type for server files.
// Throws when the source has no write target.
async function saveFile(node, content, contentType) {
if (node.handle && typeof node.handle.createWritable === 'function') {
var up = window.app.modules.upload;
if (up && up.ensureWritable) await up.ensureWritable();
var writable = await node.handle.createWritable();
await writable.write(content);
await writable.close();
return;
}
if (node.url && window.app.state.source === 'server') {
var resp = await fetch(node.url, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: content,
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
throw new Error('No write target for this file (read-only source).');
}
window.app.modules.util = {
escapeHtml: escapeHtml,
hashContent: hashContent,
isoDateToday: isoDateToday,
isoDatePlus: isoDatePlus,
yamlQuote: yamlQuote,
fetchAccessEmails: fetchAccessEmails,
fmtSize: fmtSize,
isZipMemberNode: isZipMemberNode,
saveFile: saveFile
};
})();