Compare commits

..

6 commits

Author SHA1 Message Date
d524966f00 perf(browse): stream files into the offline zip instead of buffering all bytes
downloadFsSubtree pre-read every file's arrayBuffer() and handed the raw
ArrayBuffer to JSZip, so the entire subtree's bytes sat in the JS heap at
once before zipping — the likely OOM on a large local folder despite the
size warning. Hand JSZip the File (a Blob backed by disk) instead; it reads
each lazily during generateAsync, dropping peak memory to roughly the zip
output plus JSZip's working set.

Also document, on downloadUrl, why server-side download errors aren't
surfaced as toasts: the <a download> click is fire-and-forget, and the
folder path targets zddc-server's streamed virtual "<dir>.zip" endpoint —
routing it through fetch() to make errors catchable would defeat the
streaming for arbitrarily large archives. Left as a known, documented
limitation rather than a buffering regression.

All 6 browse Playwright specs pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:33:50 -05:00
b2c16063c4 refactor(browse): remove dead code, document state shape
Pure cleanup, no behavior change:

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

All 6 browse Playwright specs pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:32:05 -05:00
b0d0ff13cd fix(browse): serialize navigation — nav-sequence token + per-node load guard
Every async flow that ends by replacing the tree root (refreshListing,
rescopeServer, reloadDir, and the app.js back/forward popstate handler) ran
without any concurrency guard. Two overlapping listings — a double-click into
a folder, a refresh fired mid-load, rapid back/forward — could resolve out of
order, so a slow fetch would setRoot/pushState on top of a newer navigation
and leave the tree out of sync with state.currentPath and the URL bar.

Introduce a shared monotonic nav-sequence token in events.js (beginNav /
isCurrentNav, exported so the app.js popstate handler joins the same
sequence). Each flow claims a token before its fetch and bails if a newer
navigation has started by the time it resolves — last navigation wins,
stale ones drop their result before mutating anything. navigateIntoFolder's
FS branch is reordered to mutate scope state only after a successful fetch +
token check, so a bail leaves the previous scope intact instead of
half-swapped.

Duplicate-fetch race fixed at the source: tree.loadChildren took only a
`loaded` check, so rapid Enter/ArrowRight key-repeat or a double-click
landing during a single-click's load fired two concurrent fetches that raced
in setChildren. Added a `loading` in-flight flag that serializes per-node
loads — the second caller is a no-op until the first resolves. This also
removes the need to await the fire-and-forget toggleFolder calls in the
keyboard handler.

Also surfaces reloadDir fetch failures via statusError instead of swallowing
them (the success path's create/rename/delete toast no longer hides a failed
refresh).

All 6 browse Playwright specs pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:21:57 -05:00
bbbf5326e7 refactor(browse): consolidate duplicated helpers into util.js; fix YAML save divergence
Nine copies of escapeHtml (some escaping single-quotes + handling null,
others not), two byte-identical hashContent hashers, two saveContent
writers, two isZipMemberNode predicates, the ISO-date + YAML-quote helpers
duplicated across the workflow modals, three /.profile/access email
fetchers, and three byte-size formatters had all drifted across the browse
modules. Hoist a single browse-local window.app.modules.util (no new global;
concatenated right after init.js) and alias the call sites to it.

Reliability fix folded in: the YAML editor's saveContent skipped the
upload.ensureWritable() escalation that the markdown editor performs, so
saving a .yaml/.zddc file to a read-only-picked local folder failed where
markdown succeeded. Both now go through util.saveFile, which always
escalates — the shared writer makes the two editors impossible to drift
apart again.

Canonical escapeHtml is the strict superset (escapes & < > " ', null →
"") so it's a safe drop-in for every prior variant. fmtSize gains the GB
tier everywhere (history.js previously capped at MB). Also removes the dead
stage.js fetchSelfEmail (defined, never called).

Net −200 lines across the modules. No behavior change beyond the save fix;
all 6 browse Playwright specs pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:07:00 -05:00
41d4e59899 fix(browse): refresh tree after workflow moves, guard double-submit, fix modal listener leaks
Workflow data-consistency cleanup across the transmittal modules.

Stale-tree / re-trigger hazard: Stage, Unstage, and Accept reported success
with "reload to see the move" and never refreshed, leaving the moved item at
its old location in the tree — inviting the user to re-fire the action on a
folder the server had already moved. They now refresh the current listing on
success. This also revealed that events.refreshListing was never exported,
so upload.js's comment-upload refresh (which guards on it) was silently a
no-op — exporting it fixes that path too.

Non-atomic stage: "New folder" does mkdir then a separate move; if the move
failed after the mkdir succeeded the user got a generic "move failed" with an
unexplained empty folder left behind. invokeStage now tracks whether it
created the folder and says so, and refreshes so the orphan is visible.

Double-submit: Accept / Plan Review / Stage / Unstage take a module-level
busy guard so a second menu click while a POST is in flight is ignored.

Modal listener leaks (verified): the Escape keydown handler in accept,
plan-review, and create-transmittal was only removed on the Escape path —
cancel / overlay-click / submit all leaked a live document listener bound to
a detached modal. Bound once and removed in close() (matching history.js).

history.js restore: split the PUT from the post-restore refetch so a refetch
error can no longer surface a misleading "Restore failed" after the restore
has already persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:59:23 -05:00
cfb2fab401 fix(browse): editor lifecycle — dispose on switch, guard unsaved edits, kill leaks
The markdown/YAML preview editors were never disposed when switching to a
non-editor file: dispose() was only called from inside the same plugin's
render(), so md→PDF/image/YAML overwrote the pane via innerHTML and leaked
the Toast UI instance, its DOM, and document-level resizer drag listeners.
Unsaved edits were also discarded silently on any file switch (including
arrow-key auto-preview), and debounced change handlers could resolve after
an editor was disposed and write the wrong file's dirty/hash state.

preview.js now owns editor lifecycle centrally in renderInline:
- disposeEditors() up front before replacing the pane (fixes the leak for
  every md/yaml → anything switch).
- dirty guard: deliberate switches (click/Enter/menu) confirm before
  discarding; auto previews (keyboard cursor walking the tree, opts.auto)
  leave the dirty editor in place rather than nagging per keystroke;
  re-selecting the file already being edited is a no-op.
- a renderSeq token bails late-arriving loads so a slow file can't paint
  stale content into the pane after a newer selection.
- clearPreview() exposed and used by rescope (events.js) and popstate
  (app.js) so those resets dispose the editor instead of leaking it.
- beforeunload warns when an editor is dirty at page exit.

preview-markdown.js: per-mount AbortController wired into the resizer
document listeners so dispose() detaches them even mid-drag; debounced
change/save/convert handlers guard `currentInstance !== instance` so a
disposed editor's callbacks can't corrupt the active file; expose
isDirty()/currentNode().

preview-yaml.js: track dirty/node state, guard the change handler the same
way, expose dispose()/isDirty()/currentNode().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:46:31 -05:00
18 changed files with 633 additions and 424 deletions

View file

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

View file

@ -25,28 +25,10 @@
if (t) t(msg, level || 'info');
}
function isoDateToday() {
var d = new Date();
return d.getFullYear()
+ '-' + ('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];
});
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
var isoDateToday = util.isoDateToday;
var isoDatePlus = util.isoDatePlus;
// Is this node a direct child of an incoming/ canonical folder
// AND a well-formed transmittal folder? The first half is the
@ -96,19 +78,7 @@
return out;
}
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 []; });
}
var fetchPeopleSuggestions = util.fetchAccessEmails;
function openForm(initial) {
return new Promise(function (resolve, reject) {
@ -189,7 +159,15 @@
});
});
// 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() {
document.removeEventListener('keydown', onKeydown);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
}
box.querySelector('#acc-cancel').addEventListener('click', function () {
@ -198,12 +176,7 @@
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
close(); reject(new Error('cancelled'));
}
});
document.addEventListener('keydown', onKeydown);
box.querySelector('#acc-submit').addEventListener('click', function () {
var values = {
@ -227,9 +200,7 @@
});
}
function quote(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
var quote = util.yamlQuote;
function buildBody(values) {
var lines = ['received_date: ' + values.receivedDate];
if (values.setupPlanReview) {
@ -243,7 +214,10 @@
return lines.join('\n');
}
var busy = false;
async function invoke(node) {
if (busy) return;
var tree = window.app.modules.tree;
if (!tree) return;
var url = tree.pathFor(node);
@ -275,34 +249,43 @@
return;
}
status('Accept Transmittal — submitting…');
var resp;
busy = true;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'X-ZDDC-Op': 'accept-transmittal',
'Content-Type': 'application/yaml'
},
body: buildBody(values),
credentials: 'same-origin'
});
} catch (e) {
status('Accept failed: ' + (e && e.message ? e.message : e), 'error');
return;
status('Accept Transmittal — submitting…');
var resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'X-ZDDC-Op': 'accept-transmittal',
'Content-Type': 'application/yaml'
},
body: buildBody(values),
credentials: 'same-origin'
});
} catch (e) {
status('Accept failed: ' + (e && e.message ? e.message : e), 'error');
return;
}
if (!resp.ok) {
var text = '';
try { text = await resp.text(); } catch (_e) { /* ignore */ }
status('Accept failed (' + resp.status + '): ' + text, 'error');
return;
}
var data; try { data = await resp.json(); } catch (_e) { data = null; }
var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into '
+ (data && data.received_path ? data.received_path : 'received/');
if (data && data.merged) msg += ' (merged with existing tracking)';
if (data && data.plan_review) msg += ' · Plan Review scaffolded';
status(msg, '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;
}
if (!resp.ok) {
var text = '';
try { text = await resp.text(); } catch (_e) { /* ignore */ }
status('Accept failed (' + resp.status + '): ' + text, 'error');
return;
}
var data; try { data = await resp.json(); } catch (_e) { data = null; }
var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into '
+ (data && data.received_path ? data.received_path : 'received/');
if (data && data.merged) msg += ' (merged with existing tracking)';
if (data && data.plan_review) msg += ' · Plan Review scaffolded';
status(msg + ' — reload to see the move.', 'success');
}
window.app.modules.acceptTransmittal = {

View file

@ -8,17 +8,6 @@
var tree = window.app.modules.tree;
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.
// 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
@ -132,15 +121,26 @@
var popQS = new URLSearchParams(location.search);
if (popQS.get('hidden') === '1') window.app.state.showHidden = true;
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 {
var es = await loader.fetchServerChildren(path);
if (events.isCurrentNav && !events.isCurrentNav(seq)) return;
window.app.state.currentPath = path;
window.app.state.selectedId = null;
window.app.state.lastPreviewedNodeId = null;
tree.setRoot(es);
tree.render();
var previewBody = document.getElementById('previewBody');
if (previewBody) previewBody.innerHTML = '';
// 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');
if (previewBody) previewBody.innerHTML = '';
}
var previewTitle = document.getElementById('previewTitle');
if (previewTitle) previewTitle.textContent = 'No file selected';
// Reapply view mode for the new URL (incoming/ → grid, etc).

View file

@ -18,17 +18,9 @@
var t = window.zddc && window.zddc.toast;
if (t) t(msg, level || 'info');
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
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);
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
var isoDateToday = util.isoDateToday;
function openForm() {
return new Promise(function (resolve, reject) {
@ -78,19 +70,22 @@
input.addEventListener('input', revalidate);
revalidate();
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
// Escape handler bound once, removed in close() so it can't
// 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 () {
close(); reject(new Error('cancelled'));
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
close(); reject(new Error('cancelled'));
}
});
document.addEventListener('keydown', onKeydown);
submit.addEventListener('click', function () {
var v = input.value.trim();
var parsed = window.zddc.parseFolder(v);

View file

@ -44,6 +44,12 @@
}
// 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) {
var a = document.createElement('a');
a.href = url;
@ -97,9 +103,12 @@
var zip = new window.JSZip();
for (var i = 0; i < files.length; i++) {
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 buf = await f.arrayBuffer();
zip.file(rootHandle.name + '/' + files[i].relPath, buf);
zip.file(rootHandle.name + '/' + files[i].relPath, f);
}
ev.statusInfo('Generating ' + rootHandle.name + '.zip…');
var blob = await zip.generateAsync({ type: 'blob' });

View file

@ -133,6 +133,16 @@
} 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() {
// Snapshot expanded paths + selection BEFORE setRoot clears the
// tree, then re-apply after the new root is in place. Keeps
@ -141,6 +151,7 @@
// a refresh — including the auto-refresh triggered by the
// "Show hidden files" toggle.
var snap = tree.snapshotState();
var seq = beginNav();
if (state.source === 'server') {
var raw;
try {
@ -149,8 +160,10 @@
statusError('Refresh failed: ' + e.message);
return;
}
if (!isCurrentNav(seq)) return;
tree.setRoot(raw);
await tree.restoreState(snap);
if (!isCurrentNav(seq)) return;
tree.render();
statusInfo('Refreshed (' + raw.length + ' item'
+ (raw.length === 1 ? '' : 's') + ')');
@ -162,8 +175,10 @@
statusError('Refresh failed: ' + e.message);
return;
}
if (!isCurrentNav(seq)) return;
tree.setRoot(raw2);
await tree.restoreState(snap);
if (!isCurrentNav(seq)) return;
tree.render();
statusInfo('Refreshed');
}
@ -449,7 +464,10 @@
// selection-only; their preview is "expand to see inside".
if (nextNode && !nextNode.isDir && !nextNode.isZip
&& previewModule) {
previewModule.showFilePreview(nextNode);
// auto:true — keyboard cursor walking the tree. If an
// 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;
}
// Scroll the now-selected row into view.
@ -660,11 +678,7 @@
return parentDir;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
var escapeHtml = window.app.modules.util.escapeHtml;
// Valid party folder name — mirrors zddc.ValidPartyName server-side
// (^[A-Za-z0-9][A-Za-z0-9.-]*$).
@ -869,14 +883,20 @@
var loader = window.app.modules.loader;
if (!loader) return;
if (!dirPath.endsWith('/')) dirPath += '/';
var seq = beginNav();
// Root-scope reload — refresh the visible top-level listing.
if (dirPath === state.currentPath) {
var es;
try {
var es = state.source === 'server'
es = state.source === 'server'
? await loader.fetchServerChildren(dirPath)
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
tree.setRoot(es);
} catch (_e) { /* swallow */ }
} catch (e) {
statusError('Reload failed: ' + (e.message || e));
return;
}
if (!isCurrentNav(seq)) return;
tree.setRoot(es);
tree.render();
return;
}
@ -888,13 +908,18 @@
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
});
if (hit) {
var raw;
try {
var raw = state.source === 'server'
raw = state.source === 'server'
? await loader.fetchServerChildren(dirPath)
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
tree.setChildren(hit.id, raw);
hit.expanded = true;
} catch (_e) { /* swallow */ }
} catch (e) {
statusError('Reload failed: ' + (e.message || e));
return;
}
if (!isCurrentNav(seq)) return;
tree.setChildren(hit.id, raw);
hit.expanded = true;
tree.render();
}
}
@ -1371,8 +1396,7 @@
}
if (state.source === 'fs') {
if (!node.handle || node.handle.kind !== 'directory') return;
state.rootHandle = node.handle;
state.currentPath = node.handle.name + '/';
var seq = beginNav();
var raw;
try {
raw = await loader.fetchFsChildren(node.handle);
@ -1380,6 +1404,12 @@
statusError('Failed to enter ' + node.name + ': ' + e.message);
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.render();
statusInfo('Entered ' + node.name);
@ -1390,6 +1420,7 @@
// history.pushState, fetches the new directory listing, and
// re-renders the tree from scratch. Page DOES NOT reload.
async function rescopeServer(url, displayName) {
var seq = beginNav();
var entries;
try {
entries = await loader.fetchServerChildren(url);
@ -1397,6 +1428,10 @@
statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
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;
// Selection / preview belong to the old scope; clear them so
// the new root doesn't carry stale highlight state.
@ -1408,9 +1443,14 @@
tree.setRoot(entries);
tree.render();
// Reset the preview pane so the user sees an "empty selection"
// state at the new scope instead of the previous file.
var previewBody = document.getElementById('previewBody');
if (previewBody) previewBody.innerHTML = '';
// state at the new scope instead of the previous file. Route
// 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');
if (previewBody) previewBody.innerHTML = '';
}
var previewTitle = document.getElementById('previewTitle');
if (previewTitle) previewTitle.textContent = 'No file selected';
var previewMeta = document.getElementById('previewMeta');
@ -1439,6 +1479,16 @@
statusInfo: statusInfo,
statusClear: statusClear,
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,11 +16,7 @@
(function () {
'use strict';
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var escapeHtml = window.app.modules.util.escapeHtml;
function toast(msg, kind) {
if (window.zddc && typeof window.zddc.toast === 'function') {
@ -40,12 +36,7 @@
return d.toLocaleString();
}
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';
}
var fmtBytes = window.app.modules.util.fmtSize;
// Can the principal write (restore) to this file? Mirrors the
// events.js Rename/Delete gating: verbs===undefined means a non-zddc
@ -346,6 +337,10 @@
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
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 {
var text = await fetchVersion(node, ent.id);
var resp = await fetch(node.url, {
@ -355,18 +350,22 @@
body: text
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
toast('Restored version from ' + fmtTime(ent.ts), 'success');
// Reflect the new head: refetch the list.
} catch (e) {
toast('Restore failed: ' + (e.message || e), 'error');
return;
}
toast('Restored version from ' + fmtTime(ent.ts), 'success');
// Best-effort UI refresh — the restore already succeeded, so a
// failure here is logged but never reported as a restore failure.
try {
var entries = await fetchList(node);
renderList(modal, node, entries);
// If the file is open in the preview pane, reload it.
var preview = window.app && window.app.modules && window.app.modules.preview;
if (preview && typeof preview.showFilePreview === 'function') {
try { preview.showFilePreview(node); } catch (_e) { /* best effort */ }
preview.showFilePreview(node);
}
} catch (e) {
toast('Restore failed: ' + (e.message || e), 'error');
}
} catch (_e) { /* refresh is best-effort; restore is done */ }
}
// ── Entry point ─────────────────────────────────────────────────────

View file

@ -58,22 +58,10 @@
currentRow = null;
}
// ── Formatting (kept local so this module is self-contained) ──
// ── Formatting ──
function escapeHtml(s) {
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';
}
var escapeHtml = window.app.modules.util.escapeHtml;
var fmtSize = window.app.modules.util.fmtSize;
function fmtDate(d) {
if (!d) return '';

View file

@ -69,8 +69,19 @@
// scopeDropTarget: cascade's drop_target at currentPath
// scopeDefaultTool: cascade's default_tool at currentPath
// (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,
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
// don't match and folders whose subtree has no matches.

View file

@ -11,10 +11,12 @@
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) {
var i = name.lastIndexOf('.');
if (i <= 0 || i === name.length - 1) return '';
return name.substring(i + 1).toLowerCase();
return window.zddc.splitExtension(name).extension;
}
// Build a raw entry from the server's FileInfo shape.
@ -222,7 +224,6 @@
fetchServerChildren: fetchServerChildren,
fetchFsChildren: fetchFsChildren,
autoDetectServerMode: autoDetectServerMode,
splitExt: splitExt,
ensureJSZip: ensureJSZip
};
})();

View file

@ -45,44 +45,18 @@
}
}
// Compute today + N days as a YYYY-MM-DD string.
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;
}
var util = window.app.modules.util;
var isoDatePlus = util.isoDatePlus;
// Fetch suggestion emails from /.profile/access so the originator
// field has a datalist of likely values. Best-effort — silent on
// failure (the field still accepts free text).
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 [];
}
}
var fetchOriginatorSuggestions = util.fetchAccessEmails;
// Build the YAML body for the plan-review POST. Quoting is minimal
// (just enough for emails with special chars).
function buildBody(values) {
function yamlString(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
var yamlString = util.yamlQuote;
return [
'review_lead: ' + yamlString(values.reviewLead),
'approver: ' + yamlString(values.approver),
@ -145,7 +119,14 @@
});
});
// 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() {
document.removeEventListener('keydown', onKeydown);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
}
@ -159,13 +140,7 @@
reject(new Error('cancelled'));
}
});
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
close();
reject(new Error('cancelled'));
}
});
document.addEventListener('keydown', onKeydown);
box.querySelector('#pr-submit').addEventListener('click', function () {
var values = {
@ -187,14 +162,7 @@
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
var escapeHtml = util.escapeHtml;
// Detect whether a tree node is an archive/<party>/received/<tracking>/
// folder. The path is path-shaped, not content-based — tracking-number
@ -211,8 +179,11 @@
&& parts[3].toLowerCase() === 'received';
}
var busy = false;
// Run the Plan Review flow: open the modal, POST the result.
async function invoke(node) {
if (busy) return;
var tree = window.app.modules.tree;
if (!tree) return;
var url = tree.pathFor(node);
@ -227,43 +198,48 @@
return; // cancelled
}
statusInfo('Plan Review — submitting…');
var body = buildBody(values);
var resp;
busy = true;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'X-ZDDC-Op': 'plan-review',
'Content-Type': 'application/yaml'
},
body: body,
credentials: 'same-origin'
});
} catch (e) {
statusError('Plan Review failed: ' + (e && e.message ? e.message : e));
return;
}
if (!resp.ok) {
var text = '';
try { text = await resp.text(); } catch (_e) { /* ignore */ }
statusError('Plan Review failed (' + resp.status + '): ' + text);
return;
}
var data;
try { data = await resp.json(); } catch (_e) { data = null; }
if (data && data.reviewing && data.staging) {
var rPart = data.reviewing.created ? 'created' : 'updated';
var sPart = data.staging.created ? 'created' : 'updated';
var seal = (data.received && data.received.created)
? ' Canonical record sealed.'
: (data.received && !data.received.zddc_written)
? ' Canonical dates left untouched (already sealed).'
: '';
statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal +
' Reload the relevant folder to see the new entries.');
} else {
statusInfo('Plan Review complete.');
statusInfo('Plan Review — submitting…');
var body = buildBody(values);
var resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'X-ZDDC-Op': 'plan-review',
'Content-Type': 'application/yaml'
},
body: body,
credentials: 'same-origin'
});
} catch (e) {
statusError('Plan Review failed: ' + (e && e.message ? e.message : e));
return;
}
if (!resp.ok) {
var text = '';
try { text = await resp.text(); } catch (_e) { /* ignore */ }
statusError('Plan Review failed (' + resp.status + '): ' + text);
return;
}
var data;
try { data = await resp.json(); } catch (_e) { data = null; }
if (data && data.reviewing && data.staging) {
var rPart = data.reviewing.created ? 'created' : 'updated';
var sPart = data.staging.created ? 'created' : 'updated';
var seal = (data.received && data.received.created)
? ' Canonical record sealed.'
: (data.received && !data.received.zddc_written)
? ' Canonical dates left untouched (already sealed).'
: '';
statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal +
' Reload the relevant folder to see the new entries.');
} else {
statusInfo('Plan Review complete.');
}
} finally {
busy = false;
}
}

View file

@ -42,34 +42,39 @@
var SIDEBAR_DEFAULT_WIDTH = 280;
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
var hashContent = util.hashContent;
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
var lastFmHeight = FM_DEFAULT_HEIGHT;
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 dispose() {
if (currentInstance && currentInstance.editor) {
try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ }
if (currentInstance) {
// Tear down the document-level resizer drag listeners (added
// lazily on mousedown). They're normally removed on mouseup,
// but a dispose mid-drag — or any switch away — would otherwise
// strand them pointing at the dead shell. The AbortController
// removes whatever is still attached in one call.
if (currentInstance.ac) {
try { currentInstance.ac.abort(); } catch (_) { /* ignore */ }
}
if (currentInstance.editor) {
try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ }
}
}
currentInstance = null;
}
function isDirty() {
return !!(currentInstance && currentInstance.dirty);
}
function currentNode() {
return currentInstance ? currentInstance.node : null;
}
// ── Front matter ────────────────────────────────────────────────────────
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
@ -273,38 +278,11 @@
// ── Save ────────────────────────────────────────────────────────────────
async function saveContent(node, content) {
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).');
function saveContent(node, content) {
return util.saveFile(node, content, 'text/markdown; charset=utf-8');
}
// 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;
}
var isZipMemberNode = util.isZipMemberNode;
function canSave(node) {
if (isZipMemberNode(node)) return false;
@ -564,15 +542,20 @@
}));
}
currentInstance = {
// One AbortController per mount — wired into the document-level
// resizer listeners below so dispose() can detach them all at once.
var ac = new AbortController();
var instance = {
editor: editor,
container: container,
dirty: false,
node: node,
hash: initialHash,
tocEl: tocBody,
fmEl: fmTextarea
fmEl: fmTextarea,
ac: ac
};
currentInstance = instance;
if (!writableMode) {
saveBtn.disabled = true;
@ -609,8 +592,8 @@
resizer.classList.add('is-dragging');
startX = e.clientX;
startW = sidebar.getBoundingClientRect().width;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('mousemove', onMove, { signal: ac.signal });
document.addEventListener('mouseup', onUp, { signal: ac.signal });
e.preventDefault();
});
resizer.addEventListener('keydown', function (e) {
@ -654,8 +637,8 @@
fmResizer.classList.add('is-dragging');
startY = e.clientY;
startH = fmSection.getBoundingClientRect().height;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('mousemove', onMove, { signal: ac.signal });
document.addEventListener('mouseup', onUp, { signal: ac.signal });
e.preventDefault();
});
fmResizer.addEventListener('keydown', function (e) {
@ -670,7 +653,8 @@
// ── Change tracking + auto-rerender ────────────────────────────────
function markDirty(isDirty) {
currentInstance.dirty = isDirty;
if (currentInstance !== instance) return; // editor replaced
instance.dirty = isDirty;
// Re-read canSave at every transition, not via a closure-captured
// value, so the gate reflects current write authority — see the
// matching pattern in preview-yaml.js.
@ -678,29 +662,40 @@
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 () {
if (currentInstance !== instance) return;
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
markDirty(h !== currentInstance.hash);
if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
renderToc(tocBody, body, editor);
}, 250);
editor.on('change', onChange);
var onFmChange = debounce(async function () {
if (currentInstance !== instance) return;
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
markDirty(h !== currentInstance.hash);
if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
}, 250);
fmTextarea.addEventListener('input', onFmChange);
// ── Save ───────────────────────────────────────────────────────────
async function save() {
if (!currentInstance.dirty || !canSave(node)) return;
if (currentInstance !== instance) return;
if (!instance.dirty || !canSave(node)) return;
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try {
statusEl.textContent = 'Saving…';
await saveContent(node, content);
currentInstance.hash = await hashContent(content);
if (currentInstance !== instance) return; // switched away mid-save
instance.hash = await hashContent(content);
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
@ -732,7 +727,7 @@
convertBtns.forEach(function (a) {
a.addEventListener('click', async function (e) {
var fmt = a.dataset.fmt;
if (!currentInstance.dirty) {
if (!instance.dirty) {
// Clean — let the browser handle the click. The
// server's response (DOCX/HTML/PDF bytes, 422,
// 503, etc.) lands in whatever target the user
@ -751,7 +746,7 @@
}
statusEl.textContent = 'Saving before download…';
try { await save(); } catch (_) { /* save() surfaces its own error */ }
if (currentInstance.dirty) return; // save failed; toast already shown
if (currentInstance !== instance || instance.dirty) return; // save failed / switched away
statusEl.textContent = 'Downloading ' + fmt.toUpperCase() + '…';
// Re-trigger the click. dirty=false now so the handler
// exits early on the second pass and the browser
@ -763,6 +758,8 @@
window.app.modules.markdown = {
render: render,
dispose: dispose
dispose: dispose,
isDirty: isDirty,
currentNode: currentNode
};
})();

View file

@ -22,10 +22,8 @@
if (!window.app || !window.app.modules) return;
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
// ── Filename routing ────────────────────────────────────────────────────
@ -47,32 +45,14 @@
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
async function saveContent(node, content) {
if (node.handle && typeof node.handle.createWritable === 'function') {
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': '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).');
function saveContent(node, content) {
// Via the shared saveFile so local (FS-Access) saves escalate to
// readwrite the same as the markdown editor — previously this path
// skipped ensureWritable and failed on read-only-picked folders.
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8');
}
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;
}
var isZipMemberNode = util.isZipMemberNode;
function canSave(node) {
if (isZipMemberNode(node)) return false;
@ -96,17 +76,7 @@
return false;
}
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;
}
var hashContent = util.hashContent;
// ── .zddc schema ────────────────────────────────────────────────────────
//
@ -378,12 +348,24 @@
// ── Mount ───────────────────────────────────────────────────────────────
var currentEditor = null;
var currentDirty = false;
var currentNodeRef = null;
function dispose() {
// CM doesn't have an explicit destroy(); GC handles it once
// the host element is removed. Clear our reference so a stale
// editor doesn't keep handlers alive.
currentEditor = null;
currentDirty = false;
currentNodeRef = null;
}
function isDirty() {
return currentDirty;
}
function currentNode() {
return currentNodeRef;
}
async function render(node, container, ctx) {
@ -499,6 +481,8 @@
// Force an initial lint pass now that _zddcNode is set.
editor.performLint();
currentEditor = editor;
currentNodeRef = node;
currentDirty = false;
if (!writable) {
saveBtn.disabled = true;
@ -514,12 +498,16 @@
var initialHash = await hashContent(text);
function markDirty(isDirty) {
if (currentEditor !== editor) return; // editor replaced
currentDirty = isDirty;
saveBtn.disabled = !isDirty || !canSave(node);
dirtyEl.textContent = isDirty ? '● modified' : '';
}
editor.on('change', async function () {
if (currentEditor !== editor) return; // switched away
var h = await hashContent(editor.getValue());
if (currentEditor !== editor) return; // replaced during await
markDirty(h !== initialHash);
});
@ -564,6 +552,9 @@
window.app.modules.yamledit = {
handles: handles,
render: render
render: render,
dispose: dispose,
isDirty: isDirty,
currentNode: currentNode
};
})();

View file

@ -19,10 +19,8 @@
console.error('[browse] zddc.preview not loaded — preview disabled.');
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
var MIME = {
'pdf': 'application/pdf',
@ -41,13 +39,7 @@
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
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';
}
var fmtSize = util.fmtSize;
async function getArrayBuffer(node) {
// A zip member node carries a ZipFileHandle in node.handle, so
@ -76,8 +68,62 @@
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 ────────────────────────────────────────────────────
// 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) {
container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>';
}
@ -87,13 +133,37 @@
+ escapeHtml(msg) + '</div>';
}
async function renderInline(node) {
async function renderInline(node, opts) {
opts = opts || {};
var container = document.getElementById('previewBody');
var titleEl = document.getElementById('previewTitle');
var metaEl = document.getElementById('previewMeta');
var popoutBtn = document.getElementById('previewPopout');
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 (metaEl) {
var meta = [];
@ -134,6 +204,7 @@
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
try {
var info = await getBlobUrl(node);
if (seq !== renderSeq) return;
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>';
} catch (e) {
@ -146,6 +217,7 @@
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
try {
var imgInfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name)
+ '" src="' + escapeHtml(imgInfo.url) + '">';
} catch (e) {
@ -157,6 +229,7 @@
if (preview && preview.isTiff(ext)) {
try {
var tiffBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = '';
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
} catch (e) {
@ -168,6 +241,7 @@
if (preview && preview.isZip(ext)) {
try {
var zipBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = '';
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
} catch (e) {
@ -182,6 +256,7 @@
if (preview && preview.isOffice(ext)) {
try {
var officeBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = '';
if (ext === 'docx') {
await preview.renderDocx(document, container, officeBuf, { fileName: node.name });
@ -197,6 +272,7 @@
if (preview && preview.isText(ext)) {
try {
var txtBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
var MAX = 200000;
if (text.length > MAX) {
@ -217,6 +293,7 @@
// Unknown type — offer a download link.
try {
var fallbackInfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML =
'<div class="preview-empty">'
+ 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. '
@ -358,11 +435,13 @@
if (node.isDir) return;
opts = opts || {};
if (opts.popup) return renderInPopup(node);
return renderInline(node);
return renderInline(node, opts);
}
window.app.modules.preview = {
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.
getArrayBuffer: getArrayBuffer
};

View file

@ -25,11 +25,16 @@
var t = window.zddc && window.zddc.toast;
if (t) t(msg, level || 'info');
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c];
});
// Re-fetch the current listing so the moved file appears/disappears
// without a manual reload. Best-effort: absent on older builds.
function refreshListing() {
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 ──────────────
// A file is stageable if its path matches
@ -90,18 +95,6 @@
.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.
async function mkdir(absUrl) {
var resp = await fetch(absUrl, {
@ -267,6 +260,7 @@
// ── Action drivers ─────────────────────────────────────────────────
async function invokeStage(node) {
if (busy) return;
var tree = window.app.modules.tree;
if (!tree) return;
var srcUrl = tree.pathFor(node);
@ -289,26 +283,46 @@
choice = await openStagePicker({ fileCount: 1, folders: folders });
} catch (_e) { return; }
if (choice.create) {
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) {
try {
await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/');
createdFolder = true;
} catch (e) {
status((e && e.message) || 'mkdir failed', 'error');
return;
}
}
var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name);
try {
await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/');
await moveFile(srcUrl, dstUrl);
} catch (e) {
status((e && e.message) || 'mkdir failed', 'error');
var msg = (e && e.message) || 'move failed';
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;
}
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/', 'success');
refreshListing();
} finally {
busy = false;
}
var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name);
try {
await moveFile(srcUrl, dstUrl);
} catch (e) {
status((e && e.message) || 'move failed', 'error');
return;
}
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success');
}
async function invokeUnstage(node) {
if (busy) return;
var tree = window.app.modules.tree;
if (!tree) return;
var srcUrl = tree.pathFor(node);
@ -326,13 +340,19 @@
var target = choice.target;
if (!target.endsWith('/')) target += '/';
var dstUrl = target + encodeURIComponent(node.name);
busy = true;
try {
await moveFile(srcUrl, dstUrl);
} catch (e) {
status((e && e.message) || 'move failed', 'error');
return;
try {
await moveFile(srcUrl, dstUrl);
} catch (e) {
status((e && e.message) || 'move failed', 'error');
return;
}
status('Unstaged ' + node.name + ' → ' + target, 'success');
refreshListing();
} finally {
busy = false;
}
status('Unstaged ' + node.name + ' → ' + target + ' — reload to see the move.', 'success');
}
window.app.modules.stage = {

View file

@ -211,13 +211,7 @@
// ── Rendering ────────────────────────────────────────────────────────
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';
}
var fmtSize = window.app.modules.util.fmtSize;
function fmtDate(d) {
if (!d) return '';
@ -226,10 +220,7 @@
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var escapeHtml = window.app.modules.util.escapeHtml;
// Per-extension icon map → Lucide outline-icon sprite ids. The
// actual SVG markup is produced by window.zddc.icons.html(id),
@ -469,10 +460,6 @@
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
// can't be fetched as a standalone server resource: we read them
// through the containing handle (offline / nested) or by fetching
@ -521,7 +508,14 @@
// it as a directory handle; members
// become ordinary dir/file nodes
async function loadChildren(node) {
if (node.loaded) return;
if (node.loaded || node.loading) 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 {
if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
@ -541,6 +535,8 @@
} catch (e) {
window.app.modules.events.statusError(
'Failed to load ' + node.name + ': ' + e.message);
} finally {
node.loading = false;
}
}
@ -690,15 +686,6 @@
loadChildren: loadChildren,
snapshotState: snapshotState,
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).
// Used by the toolbar's sort dropdown.
setSortExplicit: function (key, dir) {

View file

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

131
browse/js/util.js Normal file
View file

@ -0,0 +1,131 @@
// 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
};
})();