diff --git a/browse/js/accept-transmittal.js b/browse/js/accept-transmittal.js index 3bd86c6..727d531 100644 --- a/browse/js/accept-transmittal.js +++ b/browse/js/accept-transmittal.js @@ -189,7 +189,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 +206,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 = { @@ -243,7 +246,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 +281,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 = { diff --git a/browse/js/create-transmittal.js b/browse/js/create-transmittal.js index 40bfc53..9f1824a 100644 --- a/browse/js/create-transmittal.js +++ b/browse/js/create-transmittal.js @@ -78,19 +78,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); diff --git a/browse/js/events.js b/browse/js/events.js index bb2c568..fad750b 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -1447,6 +1447,11 @@ 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 }; })(); diff --git a/browse/js/history.js b/browse/js/history.js index 6428a66..e07b592 100644 --- a/browse/js/history.js +++ b/browse/js/history.js @@ -346,6 +346,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 +359,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 ───────────────────────────────────────────────────── diff --git a/browse/js/plan-review.js b/browse/js/plan-review.js index c3e6312..b284a2c 100644 --- a/browse/js/plan-review.js +++ b/browse/js/plan-review.js @@ -145,7 +145,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 +166,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 = { @@ -211,8 +212,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 +231,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; } } diff --git a/browse/js/stage.js b/browse/js/stage.js index 83c3302..7f5d4b9 100644 --- a/browse/js/stage.js +++ b/browse/js/stage.js @@ -25,6 +25,15 @@ var t = window.zddc && window.zddc.toast; if (t) t(msg, level || 'info'); } + // 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; function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]; @@ -267,6 +276,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 +299,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 +356,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 = {