ZDDC/browse/js/app.js
ZDDC 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

172 lines
8.1 KiB
JavaScript

// app.js — bootstrap. Runs after every other module's IIFE has
// registered its functions on window.app.modules.
(function () {
'use strict';
var state = window.app.state;
var loader = window.app.modules.loader;
var tree = window.app.modules.tree;
var events = window.app.modules.events;
// Virtual canonical folder injection used to live here (browse
// appended archive/working/staging/reviewing entries at a project
// root when missing). zddc-server now emits them in the listing
// directly so the .zddc `display:` map can override their labels
// the same as real entries. This pass-through stub keeps the
// events.js rescope contract intact without doing any merging.
function passThroughEntries(entries) { return entries; }
// Expose for events.js's client-side rescope on dblclick.
window.app.modules.augmentRoot = passThroughEntries;
// Walk a `?file=` path segment-by-segment from the current root.
// Each non-leaf segment is matched against the parent's children
// by name; if found and it's a folder, expand+load it (so its
// children populate state.nodes) and recurse into them. The leaf
// segment becomes the selected/previewed entry. Silently no-ops
// when any segment doesn't resolve — deep links aren't a hard
// contract, just an affordance.
async function openDeepLink(path) {
var segs = path.split('/').filter(Boolean);
if (segs.length === 0) return;
var tree = window.app.modules.tree;
var prev = window.app.modules.preview;
// Lookup helper: find a node by name within a given parent's
// immediate children. Top-level walk uses state.rootIds.
function findChild(parentIds, name) {
for (var i = 0; i < parentIds.length; i++) {
var n = window.app.state.nodes.get(parentIds[i]);
if (n && n.name === name) return n;
}
return null;
}
var ids = window.app.state.rootIds;
for (var i = 0; i < segs.length; i++) {
var node = findChild(ids, segs[i]);
if (!node) return; // segment not present in this listing
if (i === segs.length - 1) {
// Leaf — select + preview.
window.app.state.selectedId = node.id;
window.app.state.lastPreviewedNodeId = node.id;
tree.render();
if (prev && !node.isDir) prev.showFilePreview(node);
return;
}
// Intermediate — must be a folder we can expand into.
if (!(node.isDir || node.isZip)) return;
if (!node.loaded) {
await tree.toggleFolder(node.id); // loads + sets expanded
} else if (!node.expanded) {
node.expanded = true;
}
ids = node.childIds;
}
}
async function bootstrap() {
events.init();
// Honor ?file=<path> deep links: external clients (the profile
// page's "edit your .zddc files" list, future bookmarks, etc.)
// can link directly to "open browse at <dir>, with this entry
// selected and previewed". Single-segment names (?file=foo.md)
// match in the current directory; multi-segment paths
// (?file=a/b/foo.md) walk into a/ then b/ then open foo.md,
// loading intermediate directories on the way.
//
// When the LEAF (or any intermediate segment) is hidden
// (.zddc, .form.yaml, …), flip showHidden ON BEFORE the
// initial listing fetch so dotfiles appear in the tree.
var qs = new URLSearchParams(location.search);
var deepFile = qs.get('file');
// Explicit ?hidden=1 in the URL: restore the show-hidden toggle
// on reload (the URL is the persistence layer for this flag —
// see events.js syncURLToSelection).
if (qs.get('hidden') === '1') state.showHidden = true;
if (deepFile) {
var segs = deepFile.split('/').filter(Boolean);
for (var si = 0; si < segs.length; si++) {
var c = segs[si].charAt(0);
if (c === '.' || c === '_') { state.showHidden = true; break; }
}
}
// Try server auto-detect. If this page is served by zddc-server
// (or any server with a Caddy-shaped JSON listing), load the
// current directory automatically. Otherwise show the empty
// state with the "Select Directory" button.
var detected = await loader.autoDetectServerMode();
if (detected) {
tree.setRoot(detected.entries);
events.showBrowseRoot();
tree.render();
events.statusInfo('Loaded ' + detected.entries.length + ' item'
+ (detected.entries.length === 1 ? '' : 's')
+ ' from ' + detected.path);
// The initial events.init() applied view mode before the
// cascade headers were available (no fetch yet). Now that
// state.scopeDefaultTool is set from the detection
// response, re-resolve so an /incoming URL auto-activates
// grid mode.
if (events.applyResolvedViewMode) events.applyResolvedViewMode();
// Final step of the deep link: walk the path segment by
// segment, expanding + loading intermediate directories
// before opening the leaf. Single-segment names use the
// same code path with one iteration.
if (deepFile) {
await openDeepLink(deepFile);
}
}
// Else: empty state stays visible; user can click Select Directory.
// Browser back / forward: client-side rescope when the URL
// changes via popstate. We can't tell server-vs-fs mode from
// popstate alone, so only honor it in server mode.
window.addEventListener('popstate', async function () {
if (window.app.state.source !== 'server') return;
var path = location.pathname;
if (!path.endsWith('/')) path += '/';
var popQS = new URLSearchParams(location.search);
if (popQS.get('hidden') === '1') window.app.state.showHidden = true;
else window.app.state.showHidden = false;
// Join the shared nav token: rapid back/forward (or back/forward
// while an in-tool rescope is mid-flight) must not apply a stale
// listing on top of a newer one.
var seq = events.beginNav ? events.beginNav() : 0;
try {
var es = await loader.fetchServerChildren(path);
if (events.isCurrentNav && !events.isCurrentNav(seq)) return;
window.app.state.currentPath = path;
window.app.state.selectedId = null;
window.app.state.lastPreviewedNodeId = null;
tree.setRoot(es);
tree.render();
// Route through clearPreview so a live editor is disposed
// (not leaked) when back/forward swaps scope.
var pmod = window.app.modules.preview;
if (pmod && pmod.clearPreview) pmod.clearPreview();
else {
var previewBody = document.getElementById('previewBody');
if (previewBody) previewBody.innerHTML = '';
}
var previewTitle = document.getElementById('previewTitle');
if (previewTitle) previewTitle.textContent = 'No file selected';
// Reapply view mode for the new URL (incoming/ → grid, etc).
if (events.applyResolvedViewMode) events.applyResolvedViewMode();
// Re-walk ?file= so back/forward restores selection +
// expansion, not just scope.
var popFile = popQS.get('file');
if (popFile) await openDeepLink(popFile);
} catch (_e) { /* swallow — leave the tree as-is */ }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
bootstrap();
}
})();