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/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,28 +25,10 @@
if (t) t(msg, level || 'info'); if (t) t(msg, level || 'info');
} }
function isoDateToday() { var util = window.app.modules.util;
var d = new Date(); var escapeHtml = util.escapeHtml;
return d.getFullYear() var isoDateToday = util.isoDateToday;
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2) var isoDatePlus = util.isoDatePlus;
+ '-' + ('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
@ -96,19 +78,7 @@
return out; return out;
} }
function fetchPeopleSuggestions() { var fetchPeopleSuggestions = util.fetchAccessEmails;
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) {
@ -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() { 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 () {
@ -198,12 +176,7 @@
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', function escHandler(e) { document.addEventListener('keydown', onKeydown);
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 = {
@ -227,9 +200,7 @@
}); });
} }
function quote(s) { var quote = util.yamlQuote;
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) {
@ -243,7 +214,10 @@
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);
@ -275,34 +249,43 @@
return; return;
} }
status('Accept Transmittal — submitting…'); busy = true;
var resp;
try { try {
resp = await fetch(url, { status('Accept Transmittal — submitting…');
method: 'POST', var resp;
headers: { try {
'X-ZDDC-Op': 'accept-transmittal', resp = await fetch(url, {
'Content-Type': 'application/yaml' method: 'POST',
}, headers: {
body: buildBody(values), 'X-ZDDC-Op': 'accept-transmittal',
credentials: 'same-origin' 'Content-Type': 'application/yaml'
}); },
} catch (e) { body: buildBody(values),
status('Accept failed: ' + (e && e.message ? e.message : e), 'error'); credentials: 'same-origin'
return; });
} 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 = { window.app.modules.acceptTransmittal = {

View file

@ -8,17 +8,6 @@
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
@ -132,15 +121,26 @@
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();
var previewBody = document.getElementById('previewBody'); // Route through clearPreview so a live editor is disposed
if (previewBody) previewBody.innerHTML = ''; // (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'); 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,17 +18,9 @@
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');
} }
function escapeHtml(s) { var util = window.app.modules.util;
return String(s).replace(/[&<>"']/g, function (c) { var escapeHtml = util.escapeHtml;
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]; var isoDateToday = util.isoDateToday;
});
}
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) {
@ -78,19 +70,22 @@
input.addEventListener('input', revalidate); input.addEventListener('input', revalidate);
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 () { 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', function escHandler(e) { document.addEventListener('keydown', onKeydown);
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,6 +44,12 @@
} }
// 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;
@ -97,9 +103,12 @@
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();
var buf = await f.arrayBuffer(); zip.file(rootHandle.name + '/' + files[i].relPath, f);
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,6 +133,16 @@
} 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
@ -141,6 +151,7 @@
// 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 {
@ -149,8 +160,10 @@
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') + ')');
@ -162,8 +175,10 @@
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');
} }
@ -449,7 +464,10 @@
// 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) {
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; state.lastPreviewedNodeId = nextId;
} }
// Scroll the now-selected row into view. // Scroll the now-selected row into view.
@ -660,11 +678,7 @@
return parentDir; return parentDir;
} }
function escapeHtml(s) { var escapeHtml = window.app.modules.util.escapeHtml;
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.-]*$).
@ -869,14 +883,20 @@
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 {
var es = state.source === 'server' 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) : []);
tree.setRoot(es); } catch (e) {
} catch (_e) { /* swallow */ } statusError('Reload failed: ' + (e.message || e));
return;
}
if (!isCurrentNav(seq)) return;
tree.setRoot(es);
tree.render(); tree.render();
return; return;
} }
@ -888,13 +908,18 @@
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
}); });
if (hit) { if (hit) {
var raw;
try { try {
var raw = state.source === 'server' 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) : []);
tree.setChildren(hit.id, raw); } catch (e) {
hit.expanded = true; statusError('Reload failed: ' + (e.message || e));
} catch (_e) { /* swallow */ } return;
}
if (!isCurrentNav(seq)) return;
tree.setChildren(hit.id, raw);
hit.expanded = true;
tree.render(); tree.render();
} }
} }
@ -1371,8 +1396,7 @@
} }
if (state.source === 'fs') { if (state.source === 'fs') {
if (!node.handle || node.handle.kind !== 'directory') return; if (!node.handle || node.handle.kind !== 'directory') return;
state.rootHandle = node.handle; var seq = beginNav();
state.currentPath = node.handle.name + '/';
var raw; var raw;
try { try {
raw = await loader.fetchFsChildren(node.handle); raw = await loader.fetchFsChildren(node.handle);
@ -1380,6 +1404,12 @@
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);
@ -1390,6 +1420,7 @@
// 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);
@ -1397,6 +1428,10 @@
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.
@ -1408,9 +1443,14 @@
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. // state at the new scope instead of the previous file. Route
var previewBody = document.getElementById('previewBody'); // through clearPreview so a live editor is disposed (not leaked).
if (previewBody) previewBody.innerHTML = ''; var pmod = previewMod();
if (pmod && pmod.clearPreview) pmod.clearPreview();
else {
var previewBody = document.getElementById('previewBody');
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');
@ -1439,6 +1479,16 @@
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,11 +16,7 @@
(function () { (function () {
'use strict'; 'use strict';
function escapeHtml(s) { var escapeHtml = window.app.modules.util.escapeHtml;
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') {
@ -40,12 +36,7 @@
return d.toLocaleString(); return d.toLocaleString();
} }
function fmtBytes(n) { var fmtBytes = window.app.modules.util.fmtSize;
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
@ -346,6 +337,10 @@
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, {
@ -355,18 +350,22 @@
body: text body: text
}); });
if (!resp.ok) throw new Error('HTTP ' + resp.status); if (!resp.ok) throw new Error('HTTP ' + resp.status);
toast('Restored version from ' + fmtTime(ent.ts), 'success'); } catch (e) {
// Reflect the new head: refetch the list. 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); 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') {
try { preview.showFilePreview(node); } catch (_e) { /* best effort */ } preview.showFilePreview(node);
} }
} catch (e) { } catch (_e) { /* refresh is best-effort; restore is done */ }
toast('Restore failed: ' + (e.message || e), 'error');
}
} }
// ── Entry point ───────────────────────────────────────────────────── // ── Entry point ─────────────────────────────────────────────────────

View file

@ -58,22 +58,10 @@
currentRow = null; currentRow = null;
} }
// ── Formatting (kept local so this module is self-contained) ── // ── Formatting ──
function escapeHtml(s) { var escapeHtml = window.app.modules.util.escapeHtml;
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;') var fmtSize = window.app.modules.util.fmtSize;
.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,8 +69,19 @@
// 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,10 +11,12 @@
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) {
var i = name.lastIndexOf('.'); return window.zddc.splitExtension(name).extension;
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.
@ -222,7 +224,6 @@
fetchServerChildren: fetchServerChildren, fetchServerChildren: fetchServerChildren,
fetchFsChildren: fetchFsChildren, fetchFsChildren: fetchFsChildren,
autoDetectServerMode: autoDetectServerMode, autoDetectServerMode: autoDetectServerMode,
splitExt: splitExt,
ensureJSZip: ensureJSZip ensureJSZip: ensureJSZip
}; };
})(); })();

View file

@ -45,44 +45,18 @@
} }
} }
// Compute today + N days as a YYYY-MM-DD string. var util = window.app.modules.util;
function isoDatePlus(days) { var isoDatePlus = util.isoDatePlus;
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).
async function fetchOriginatorSuggestions() { var fetchOriginatorSuggestions = util.fetchAccessEmails;
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) {
function yamlString(s) { var yamlString = util.yamlQuote;
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),
@ -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() { function close() {
document.removeEventListener('keydown', onKeydown);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay); if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
} }
@ -159,13 +140,7 @@
reject(new Error('cancelled')); reject(new Error('cancelled'));
} }
}); });
document.addEventListener('keydown', function escHandler(e) { document.addEventListener('keydown', onKeydown);
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 = {
@ -187,14 +162,7 @@
}); });
} }
function escapeHtml(s) { var escapeHtml = util.escapeHtml;
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
@ -211,8 +179,11 @@
&& 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);
@ -227,43 +198,48 @@
return; // cancelled return; // cancelled
} }
statusInfo('Plan Review — submitting…'); busy = true;
var body = buildBody(values);
var resp;
try { try {
resp = await fetch(url, { statusInfo('Plan Review — submitting…');
method: 'POST', var body = buildBody(values);
headers: { var resp;
'X-ZDDC-Op': 'plan-review', try {
'Content-Type': 'application/yaml' resp = await fetch(url, {
}, method: 'POST',
body: body, headers: {
credentials: 'same-origin' 'X-ZDDC-Op': 'plan-review',
}); 'Content-Type': 'application/yaml'
} catch (e) { },
statusError('Plan Review failed: ' + (e && e.message ? e.message : e)); body: body,
return; credentials: 'same-origin'
} });
if (!resp.ok) { } catch (e) {
var text = ''; statusError('Plan Review failed: ' + (e && e.message ? e.message : e));
try { text = await resp.text(); } catch (_e) { /* ignore */ } return;
statusError('Plan Review failed (' + resp.status + '): ' + text); }
return; if (!resp.ok) {
} var text = '';
var data; try { text = await resp.text(); } catch (_e) { /* ignore */ }
try { data = await resp.json(); } catch (_e) { data = null; } statusError('Plan Review failed (' + resp.status + '): ' + text);
if (data && data.reviewing && data.staging) { return;
var rPart = data.reviewing.created ? 'created' : 'updated'; }
var sPart = data.staging.created ? 'created' : 'updated'; var data;
var seal = (data.received && data.received.created) try { data = await resp.json(); } catch (_e) { data = null; }
? ' Canonical record sealed.' if (data && data.reviewing && data.staging) {
: (data.received && !data.received.zddc_written) var rPart = data.reviewing.created ? 'created' : 'updated';
? ' Canonical dates left untouched (already sealed).' var sPart = data.staging.created ? 'created' : 'updated';
: ''; var seal = (data.received && data.received.created)
statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal + ? ' Canonical record sealed.'
' Reload the relevant folder to see the new entries.'); : (data.received && !data.received.zddc_written)
} else { ? ' Canonical dates left untouched (already sealed).'
statusInfo('Plan Review complete.'); : '';
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 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
function escapeHtml(s) { var util = window.app.modules.util;
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;') var escapeHtml = util.escapeHtml;
.replace(/>/g, '&gt;').replace(/"/g, '&quot;'); var hashContent = util.hashContent;
}
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;
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() { function dispose() {
if (currentInstance && currentInstance.editor) { if (currentInstance) {
try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ } // 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; 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.
@ -273,38 +278,11 @@
// ── Save ──────────────────────────────────────────────────────────────── // ── Save ────────────────────────────────────────────────────────────────
async function saveContent(node, content) { function saveContent(node, content) {
if (node.handle && typeof node.handle.createWritable === 'function') { return util.saveFile(node, content, 'text/markdown; charset=utf-8');
// 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).');
} }
// A markdown file living inside a .zip is read-only: a ZipFileHandle var isZipMemberNode = util.isZipMemberNode;
// 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;
@ -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, 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;
@ -609,8 +592,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); document.addEventListener('mousemove', onMove, { signal: ac.signal });
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp, { signal: ac.signal });
e.preventDefault(); e.preventDefault();
}); });
resizer.addEventListener('keydown', function (e) { resizer.addEventListener('keydown', function (e) {
@ -654,8 +637,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); document.addEventListener('mousemove', onMove, { signal: ac.signal });
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp, { signal: ac.signal });
e.preventDefault(); e.preventDefault();
}); });
fmResizer.addEventListener('keydown', function (e) { fmResizer.addEventListener('keydown', function (e) {
@ -670,7 +653,8 @@
// ── Change tracking + auto-rerender ──────────────────────────────── // ── Change tracking + auto-rerender ────────────────────────────────
function markDirty(isDirty) { 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 // 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.
@ -678,29 +662,40 @@
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));
markDirty(h !== currentInstance.hash); if (currentInstance !== instance) return;
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));
markDirty(h !== currentInstance.hash); if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
}, 250); }, 250);
fmTextarea.addEventListener('input', onFmChange); fmTextarea.addEventListener('input', onFmChange);
// ── Save ─────────────────────────────────────────────────────────── // ── Save ───────────────────────────────────────────────────────────
async function 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()); var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try { try {
statusEl.textContent = 'Saving…'; statusEl.textContent = 'Saving…';
await saveContent(node, content); await saveContent(node, content);
currentInstance.hash = await hashContent(content); if (currentInstance !== instance) return; // switched away mid-save
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) {
@ -732,7 +727,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 (!currentInstance.dirty) { if (!instance.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
@ -751,7 +746,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.dirty) return; // save failed; toast already shown if (currentInstance !== instance || instance.dirty) return; // save failed / switched away
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
@ -763,6 +758,8 @@
window.app.modules.markdown = { window.app.modules.markdown = {
render: render, render: render,
dispose: dispose dispose: dispose,
isDirty: isDirty,
currentNode: currentNode
}; };
})(); })();

View file

@ -22,10 +22,8 @@
if (!window.app || !window.app.modules) return; if (!window.app || !window.app.modules) return;
function escapeHtml(s) { var util = window.app.modules.util;
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;') var escapeHtml = util.escapeHtml;
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Filename routing ──────────────────────────────────────────────────── // ── Filename routing ────────────────────────────────────────────────────
@ -47,32 +45,14 @@
// ── Save (mirrors preview-markdown.js) ───────────────────────────────── // ── Save (mirrors preview-markdown.js) ─────────────────────────────────
async function saveContent(node, content) { function saveContent(node, content) {
if (node.handle && typeof node.handle.createWritable === 'function') { // Via the shared saveFile so local (FS-Access) saves escalate to
var writable = await node.handle.createWritable(); // readwrite the same as the markdown editor — previously this path
await writable.write(content); // skipped ensureWritable and failed on read-only-picked folders.
await writable.close(); return util.saveFile(node, content, 'application/x-yaml; charset=utf-8');
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 isZipMemberNode(node) { var isZipMemberNode = util.isZipMemberNode;
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;
@ -96,17 +76,7 @@
return false; return false;
} }
async function hashContent(text) { var hashContent = util.hashContent;
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 ────────────────────────────────────────────────────────
// //
@ -378,12 +348,24 @@
// ── 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) {
@ -499,6 +481,8 @@
// 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;
@ -514,12 +498,16 @@
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);
}); });
@ -564,6 +552,9 @@
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,10 +19,8 @@
console.error('[browse] zddc.preview not loaded — preview disabled.'); console.error('[browse] zddc.preview not loaded — preview disabled.');
} }
function escapeHtml(s) { var util = window.app.modules.util;
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;') var escapeHtml = util.escapeHtml;
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var MIME = { var MIME = {
'pdf': 'application/pdf', 'pdf': 'application/pdf',
@ -41,13 +39,7 @@
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; } function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
function fmtSize(bytes) { var fmtSize = util.fmtSize;
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
@ -76,8 +68,62 @@
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>';
} }
@ -87,13 +133,37 @@
+ escapeHtml(msg) + '</div>'; + escapeHtml(msg) + '</div>';
} }
async function renderInline(node) { async function renderInline(node, opts) {
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 = [];
@ -134,6 +204,7 @@
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) {
@ -146,6 +217,7 @@
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) {
@ -157,6 +229,7 @@
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) {
@ -168,6 +241,7 @@
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) {
@ -182,6 +256,7 @@
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 });
@ -197,6 +272,7 @@
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) {
@ -217,6 +293,7 @@
// 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>. '
@ -358,11 +435,13 @@
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); return renderInline(node, opts);
} }
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,11 +25,16 @@
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');
} }
function escapeHtml(s) { // Re-fetch the current listing so the moved file appears/disappears
return String(s).replace(/[&<>"']/g, function (c) { // without a manual reload. Best-effort: absent on older builds.
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]; 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 ────────────── // ── Scope detection: path-shape, not cascade-content ──────────────
// A file is stageable if its path matches // A file is stageable if its path matches
@ -90,18 +95,6 @@
.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, {
@ -267,6 +260,7 @@
// ── 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);
@ -289,26 +283,46 @@
choice = await openStagePicker({ fileCount: 1, folders: folders }); choice = await openStagePicker({ fileCount: 1, folders: folders });
} catch (_e) { return; } } 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 { try {
await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/'); await moveFile(srcUrl, dstUrl);
} catch (e) { } 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; 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) { 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);
@ -326,13 +340,19 @@
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 {
await moveFile(srcUrl, dstUrl); try {
} catch (e) { await moveFile(srcUrl, dstUrl);
status((e && e.message) || 'move failed', 'error'); } catch (e) {
return; 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 = { window.app.modules.stage = {

View file

@ -211,13 +211,7 @@
// ── Rendering ──────────────────────────────────────────────────────── // ── Rendering ────────────────────────────────────────────────────────
function fmtSize(bytes) { var fmtSize = window.app.modules.util.fmtSize;
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 '';
@ -226,10 +220,7 @@
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
} }
function escapeHtml(s) { var escapeHtml = window.app.modules.util.escapeHtml;
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),
@ -469,10 +460,6 @@
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
@ -521,7 +508,14 @@
// 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) 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 { 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) + '/'));
@ -541,6 +535,8 @@
} 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;
} }
} }
@ -690,15 +686,6 @@
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,20 +286,11 @@
if (lastResolved) msg += ' — last at ' + lastResolved; if (lastResolved) msg += ' — last at ' + lastResolved;
note(msg, 'success'); note(msg, 'success');
} }
// Reload the listing of the workflow folder so the new +Cn file // Reload the current listing so the new +Cn file appears in the
// appears in the tree. The workflow folder is the parent of the // tree. Best-effort.
// 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') { if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
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 */ }
} }

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
};
})();