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

333 lines
14 KiB
JavaScript

// hovercard.js — rich-metadata tooltip for tree rows.
//
// Replaces the native title="…" attribute with a custom card that
// surfaces every field we know about for a row: parsed ZDDC fields
// (trackingNumber / revision / status / title / date), type, size,
// modTime, on-server path, and URL. A delayed reveal (~350 ms) keeps
// the card out of the way during fast traversal; it dismisses on
// any click, right-click, scroll, or row change.
//
// Singleton DOM element appended to <body>; positioned fixed.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
var SHOW_DELAY_MS = 350;
// Grace period after the cursor leaves the row before the card
// hides. Lets the user move INTO the card to select / copy text;
// the card cancels this timer on mouseenter.
var HIDE_DELAY_MS = 200;
var state = window.app.state;
var card = null;
var showTimer = null;
var hideTimer = null;
var currentRow = null;
function ensureCard() {
if (card) return card;
card = document.createElement('div');
card.className = 'tree-hovercard';
card.setAttribute('aria-hidden', 'true');
// Mouse interaction inside the card: cancel any pending hide
// so the user can stay in it as long as they want, then re-
// schedule on leave. Pointer-events:auto in the CSS lets the
// mouse enter; user-select:text (default) lets them drag a
// selection; right-click inside fires the browser's native
// Copy menu since we never call preventDefault for it here.
card.addEventListener('mouseenter', cancelHide);
card.addEventListener('mouseleave', scheduleHide);
document.body.appendChild(card);
return card;
}
function cancelHide() {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
}
function scheduleHide() {
cancelHide();
hideTimer = setTimeout(hide, HIDE_DELAY_MS);
}
function hide() {
if (showTimer) { clearTimeout(showTimer); showTimer = null; }
cancelHide();
if (card) card.classList.remove('is-visible');
currentRow = null;
}
// ── Formatting ──
var escapeHtml = window.app.modules.util.escapeHtml;
var fmtSize = window.app.modules.util.fmtSize;
function fmtDate(d) {
if (!d) return '';
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function typeLabelFor(node) {
if (node.isDir) return 'Folder';
if (node.isZip) return 'Zip archive';
if (node.ext) return node.ext.toUpperCase() + ' file';
return 'File';
}
var VERB_NAMES = { r: 'read', w: 'write', c: 'create', d: 'delete', a: 'admin' };
function verbsLabel(verbs) {
return ['r', 'w', 'c', 'd', 'a']
.filter(function (v) { return verbs.indexOf(v) !== -1; })
.map(function (v) { return VERB_NAMES[v]; })
.join(', ');
}
// permsValue renders the per-entry verb set the principal holds here.
// Server mode: node.verbs ("rwcda" subset). Offline (FS-API) mode has
// no ACL — access is whatever the filesystem grants.
function permsValue(verbs) {
if (typeof verbs !== 'string') {
return state.source === 'fs' ? 'local folder (filesystem)' : 'unknown';
}
if (!verbs) return 'none (read-only)';
return verbsLabel(verbs) + ' (' + verbs + ')';
}
function buildRowsHtml(node) {
var tree = window.app.modules.tree;
var z = window.zddc;
var parsed = null;
if (z) {
parsed = node.isDir
? z.parseFolder(node.name)
: z.parseFilename(node.name);
}
var html = '';
// ZDDC fields first when the basename parses.
if (parsed && parsed.valid) {
if (parsed.date) html += kv('Date', parsed.date, true);
if (parsed.trackingNumber) html += kv('Tracking number', parsed.trackingNumber, true);
if (parsed.revision) html += kv('Revision', parsed.revision, true);
if (parsed.status) html += kv('Status', parsed.status, true);
if (parsed.title) html += kv('Title', parsed.title);
// Archive references — the /<project>/.archive/<tracking>.html
// URL is the latest issued version (highest base rev), and
// /<project>/.archive/<tracking>_<rev>.html pins the exact
// revision the user is currently hovering. The dispatcher
// canonicalises both forms to project-root so links work
// from any depth.
if (parsed.trackingNumber) {
var fullPath = tree ? tree.pathFor(node) : '';
var rel = fullPath.replace(/^\/+|\/+$/g, '');
var firstSeg = rel ? rel.split('/')[0] : '';
if (firstSeg) {
var encProject = encodeURIComponent(firstSeg);
var encTracking = encodeURIComponent(parsed.trackingNumber);
var latestUrl = '/' + encProject + '/.archive/' + encTracking + '.html';
var latestLbl = '.archive/' + parsed.trackingNumber + '.html';
html += kvLink('Latest', latestUrl, latestLbl);
if (!node.isDir && parsed.revision) {
var encRev = encodeURIComponent(parsed.revision);
var inspectUrl = '/' + encProject + '/.archive/' + encTracking + '_' + encRev + '.html';
var inspectLbl = '.archive/' + parsed.trackingNumber + '_' + parsed.revision + '.html';
html += kvLink('This revision', inspectUrl, inspectLbl);
}
}
}
html += '<div class="tree-hovercard__sep"></div>';
} else if (node.displayName) {
// Operator-supplied display name — only useful as info if
// it differs from the on-disk name.
html += kv('Display name', node.displayName);
}
html += kv('Type', typeLabelFor(node));
if (!node.isDir) html += kv('Filename', node.name, true);
if (!node.isDir && node.size != null) html += kv('Size', fmtSize(node.size));
if (node.modTime) html += kv('Modified', fmtDate(node.modTime));
if (node.virtual) html += kv('Virtual', 'Not yet created on disk');
// ── Effective access for the current principal at this location ──
// "Your permissions" is the per-entry verb set (sync, from the
// listing). "Your roles" is cascade-scoped — it can differ by
// location — so it needs a path-scoped fetch; render a placeholder
// that fillRoles() updates once /.profile/access?path= resolves.
html += '<div class="tree-hovercard__sep"></div>';
html += kv('Your permissions', permsValue(node.verbs));
if (state.source === 'server') {
html += '<span class="tree-hovercard__key">Your roles</span>'
+ '<span class="tree-hovercard__val" id="hc-roles">…</span>';
}
// Path comes last (longest, most likely to wrap).
var path = tree ? tree.pathFor(node) : '';
if (path) html += kv('Path', path, true);
if (node.url && node.url !== path) html += kv('URL', node.url, true);
return html;
}
function kv(key, val, mono) {
return '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
+ '<span class="tree-hovercard__val'
+ (mono ? ' tree-hovercard__val--mono' : '')
+ '">' + escapeHtml(val) + '</span>';
}
// kvLink — value rendered as an <a> the user can click (opens in
// a new tab so the hover context isn't lost) or right-click to
// copy. Used for the .archive references on ZDDC files.
function kvLink(key, href, label) {
return '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
+ '<span class="tree-hovercard__val tree-hovercard__val--mono">'
+ '<a href="' + escapeHtml(href) + '" target="_blank" rel="noopener">'
+ escapeHtml(label)
+ '</a>'
+ '</span>';
}
function render(node) {
var z = window.zddc;
var parsed = z
? (node.isDir ? z.parseFolder(node.name) : z.parseFilename(node.name))
: null;
var primary, secondary = '';
if (parsed && parsed.valid) {
primary = parsed.title;
var parts = node.isDir
? [parsed.date, parsed.trackingNumber, parsed.status]
: [parsed.trackingNumber, parsed.revision, parsed.status];
secondary = parts.filter(Boolean).join(' · ');
} else if (node.displayName) {
primary = node.displayName;
} else {
primary = node.name;
}
card.innerHTML = ''
+ '<div class="tree-hovercard__header">'
+ '<div class="tree-hovercard__title">' + escapeHtml(primary) + '</div>'
+ (secondary
? '<div class="tree-hovercard__sub">' + escapeHtml(secondary) + '</div>'
: '')
+ '</div>'
+ '<div class="tree-hovercard__list">' + buildRowsHtml(node) + '</div>';
}
function position(row) {
// Two-pass measure: temporarily make visible-but-invisible so
// we can read offsetWidth / offsetHeight, compute placement,
// then reveal at the final coordinates.
card.style.left = '0px';
card.style.top = '0px';
card.style.visibility = 'hidden';
card.classList.add('is-visible');
var cw = card.offsetWidth;
var ch = card.offsetHeight;
var rect = row.getBoundingClientRect();
var GAP = 8;
var x = rect.right + GAP;
if (x + cw > window.innerWidth - GAP) {
x = rect.left - cw - GAP;
}
if (x < GAP) {
// Fallback: anchor under the row (last resort when the
// pane is wide enough that neither side fits).
x = Math.max(GAP, Math.min(rect.left, window.innerWidth - cw - GAP));
}
var y = rect.top;
if (y + ch > window.innerHeight - GAP) {
y = Math.max(GAP, window.innerHeight - ch - GAP);
}
if (y < GAP) y = GAP;
card.style.left = x + 'px';
card.style.top = y + 'px';
card.style.visibility = '';
}
function showFor(row, node) {
ensureCard();
render(node);
position(row);
card.classList.add('is-visible');
fillRoles(row, node);
}
// Async-fill the "Your roles" row from the path-scoped access view
// (zddc.cap.at memoises per path, so repeat hovers are instant).
// Bails if the card has moved to another row before the fetch lands.
async function fillRoles(row, node) {
if (state.source !== 'server') return;
if (!window.zddc || !window.zddc.cap) return;
var tree = window.app.modules.tree;
var path = tree ? tree.pathFor(node) : '';
if (!path) return;
var view;
try { view = await window.zddc.cap.at(path); } catch (_e) { return; }
if (currentRow !== row) return;
var el = card && card.querySelector('#hc-roles');
if (!el) return;
var roles = (view && Array.isArray(view.path_roles)) ? view.path_roles : [];
el.textContent = roles.length ? roles.join(', ') : 'none';
}
function init() {
var treeBody = document.getElementById('treeBody');
if (!treeBody) return;
treeBody.addEventListener('mouseover', function (e) {
// Returning to the tree from the card cancels any pending
// hide; the show logic below handles row changes.
cancelHide();
var row = e.target.closest('.tree-row');
if (row === currentRow) return;
// Row → row or row → empty space — reset.
if (showTimer) { clearTimeout(showTimer); showTimer = null; }
if (card) card.classList.remove('is-visible');
currentRow = row || null;
if (!row) return;
showTimer = setTimeout(function () {
if (currentRow !== row) return;
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (node) showFor(row, node);
}, SHOW_DELAY_MS);
});
// Leaving the tree schedules a hide rather than hiding
// immediately, so the cursor has time to traverse the gap to
// the card. The card's own mouseenter cancels the hide.
treeBody.addEventListener('mouseleave', scheduleHide);
treeBody.addEventListener('contextmenu', hide);
window.addEventListener('scroll', hide, true);
window.addEventListener('resize', hide);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') hide();
});
// Click anywhere outside the card dismisses it. Clicks INSIDE
// the card are allowed through so the user can drag-select
// text, right-click for the browser's native Copy menu, or
// hit Ctrl/Cmd-C.
document.addEventListener('mousedown', function (e) {
if (!card || !card.classList.contains('is-visible')) return;
if (card.contains(e.target)) return;
hide();
}, true);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.app.modules.hovercard = { hide: hide };
})();