Two layers shipped together since the second builds on the first. LAYER 1 — reviewing/ + Plan Review scaffolding - reviewing/ is now a real folder under each project, populated by the Plan Review composite endpoint. The old reviewing/ virtual aggregator handler is retired. - POST /<project>/archive/<party>/received/<tracking>/ with X-ZDDC-Op: plan-review scaffolds physical workflow folders under reviewing_root and staging_root, each carrying .zddc.received_path pointing back at the canonical submittal. Idempotent re-runs match by received_path and re-converge the ACL. - Virtual received window: when listing or writing under <workflow>/received/, the server resolves through the canonical archive/<party>/received/<tracking>/ via the workflow's .zddc.received_path. Writes get rewritten to <workflow>/<base>+C<n><suffix> so review comments land in the workflow folder and never touch the WORM archive. - Cascade defaults declare on_plan_review per project so the reviewing_root and staging_root are configurable. LAYER 2 — browse context-menu workflows - Accept Transmittal: right-click a transmittal folder in archive/<party>/incoming/ → validates ZDDC folder + filename conformance, atomic-renames the folder to archive/<party>/received/<tracking>/ (WORM zone), and optionally chains into Plan Review in the same composite request. Re-acceptance with a different revision merges file-by-file; WORM forbids overwrite of an existing filename. - Stage / Unstage: right-click files in working/<…>/ → "Stage to…" with picker of existing staging transmittal folders + inline "New transmittal folder…" create; right-click files in staging/<…>/ → "Unstage to working/" defaulting to the user's working/<email>/ home. Reuses the file-API move primitive. - Create Transmittal folder: right-click the staging/ pane → prompts for a ZDDC-conforming folder name with live validation; mkdir, then navigate to the new folder URL where the transmittal tool serves the editor. - Supporting infrastructure: new CanonicalFolderAt cascade lookup + X-ZDDC-Canonical-Folder response header so the browse SPA can scope-gate menu items without re-implementing the cascade client-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1049 lines
44 KiB
JavaScript
1049 lines
44 KiB
JavaScript
// events.js — wires up DOM listeners. Idempotent so app.js can call
|
|
// init() once on load.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
var tree = window.app.modules.tree;
|
|
var loader = window.app.modules.loader;
|
|
// preview module is loaded later (concat order); look it up at
|
|
// call time, not at IIFE-eval time.
|
|
function previewMod() { return window.app.modules.preview; }
|
|
|
|
function status(msg, kind) {
|
|
var el = document.getElementById('statusBar');
|
|
if (!el) return;
|
|
el.textContent = msg || '';
|
|
el.classList.remove('status-bar--error', 'status-bar--info');
|
|
if (kind === 'error') el.classList.add('status-bar--error');
|
|
if (kind === 'info') el.classList.add('status-bar--info');
|
|
}
|
|
|
|
function statusError(msg) { status(msg, 'error'); }
|
|
function statusInfo(msg) { status(msg, 'info'); }
|
|
function statusClear() { status('', null); }
|
|
|
|
async function pickLocalDir() {
|
|
if (typeof window.showDirectoryPicker !== 'function') {
|
|
statusError('Your browser does not support local folder selection. Use a recent Chromium-based browser, or open this page via zddc-server.');
|
|
return;
|
|
}
|
|
var handle;
|
|
try {
|
|
handle = await window.showDirectoryPicker({ mode: 'read' });
|
|
} catch (e) {
|
|
// User cancelled — silent
|
|
return;
|
|
}
|
|
state.source = 'fs';
|
|
state.rootHandle = handle;
|
|
state.currentPath = handle.name + '/';
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchFsChildren(handle);
|
|
} catch (e) {
|
|
statusError('Failed to read directory: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
showBrowseRoot();
|
|
tree.render();
|
|
statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's'));
|
|
}
|
|
|
|
function showBrowseRoot() {
|
|
var empty = document.getElementById('emptyState');
|
|
var root = document.getElementById('browseRoot');
|
|
if (empty) empty.classList.add('hidden');
|
|
if (root) root.classList.remove('hidden');
|
|
applySourceUI();
|
|
}
|
|
|
|
// Visual state of the "Select Directory" button + the refresh
|
|
// button depends on the source. In server mode the user is
|
|
// already viewing a server-backed listing — Select Directory
|
|
// becomes a quiet "switch to local" affordance (subtle styling),
|
|
// and the refresh button is shown. In FS mode the button is
|
|
// primary (it's how you got here) and refresh is hidden (the
|
|
// listing was already a fresh enumeration).
|
|
function applySourceUI() {
|
|
var add = document.getElementById('addDirectoryBtn');
|
|
var refresh = document.getElementById('refreshHeaderBtn');
|
|
if (add) {
|
|
if (state.source === 'server') {
|
|
add.classList.remove('btn-primary');
|
|
add.classList.add('btn--subtle');
|
|
} else {
|
|
add.classList.add('btn-primary');
|
|
add.classList.remove('btn--subtle');
|
|
}
|
|
}
|
|
if (refresh) {
|
|
if (state.source) {
|
|
refresh.classList.remove('hidden');
|
|
} else {
|
|
refresh.classList.add('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshListing() {
|
|
// Snapshot expanded paths + selection BEFORE setRoot clears the
|
|
// tree, then re-apply after the new root is in place. Keeps
|
|
// the user's layout (which folders were open, which row was
|
|
// highlighted, what the preview was pinned to) stable across
|
|
// a refresh — including the auto-refresh triggered by the
|
|
// "Show hidden files" toggle.
|
|
var snap = tree.snapshotState();
|
|
if (state.source === 'server') {
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchServerChildren(state.currentPath);
|
|
} catch (e) {
|
|
statusError('Refresh failed: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
await tree.restoreState(snap);
|
|
tree.render();
|
|
statusInfo('Refreshed (' + raw.length + ' item'
|
|
+ (raw.length === 1 ? '' : 's') + ')');
|
|
} else if (state.source === 'fs' && state.rootHandle) {
|
|
var raw2;
|
|
try {
|
|
raw2 = await loader.fetchFsChildren(state.rootHandle);
|
|
} catch (e) {
|
|
statusError('Refresh failed: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw2);
|
|
await tree.restoreState(snap);
|
|
tree.render();
|
|
statusInfo('Refreshed');
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
// Header buttons
|
|
var btn = document.getElementById('addDirectoryBtn');
|
|
if (btn) btn.addEventListener('click', pickLocalDir);
|
|
|
|
var refresh = document.getElementById('refreshHeaderBtn');
|
|
if (refresh) refresh.addEventListener('click', refreshListing);
|
|
|
|
// Tree autofilter — parses input through zddc.filter.parse so
|
|
// the same query grammar that the archive app uses (terms,
|
|
// quotes, !negation, multi-word AND) works here. The AST is
|
|
// cached on state.filterAST; tree.render reads it and skips
|
|
// non-matching rows. Escape clears.
|
|
var filterInput = document.getElementById('treeFilter');
|
|
if (filterInput) {
|
|
var filterDebounce = null;
|
|
var applyFilter = function () {
|
|
var raw = filterInput.value || '';
|
|
state.filterText = raw;
|
|
state.filterAST = raw ? window.zddc.filter.parse(raw) : null;
|
|
filterInput.classList.toggle('filter-active', !!raw);
|
|
tree.render();
|
|
};
|
|
filterInput.addEventListener('input', function () {
|
|
if (filterDebounce) clearTimeout(filterDebounce);
|
|
filterDebounce = setTimeout(applyFilter, 80);
|
|
});
|
|
filterInput.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape' && filterInput.value) {
|
|
e.preventDefault();
|
|
filterInput.value = '';
|
|
applyFilter();
|
|
}
|
|
});
|
|
}
|
|
|
|
// No view-mode buttons; mode is derived from the URL on every
|
|
// scope change (resolveViewMode below). Pass-through for the
|
|
// initial path.
|
|
applyResolvedViewMode();
|
|
|
|
// Pop-out preview button — opens the current preview in a separate window.
|
|
var popout = document.getElementById('previewPopout');
|
|
if (popout) popout.addEventListener('click', function () {
|
|
var p = previewMod();
|
|
if (p && state.lastPreviewedNodeId != null) {
|
|
var n = state.nodes.get(state.lastPreviewedNodeId);
|
|
if (n) p.showFilePreview(n, { popup: true });
|
|
}
|
|
});
|
|
|
|
// Pane resizer (tree pane width). Drag horizontally; clamps to
|
|
// [180, 60% of viewport]. State stays in-memory only — refresh
|
|
// resets to the default 360px.
|
|
var resizer = document.querySelector('.pane-resizer[data-resizer-for="tree-pane"]');
|
|
var treePane = document.getElementById('treePane');
|
|
if (resizer && treePane) {
|
|
var dragging = false;
|
|
var startX = 0;
|
|
var startWidth = 0;
|
|
resizer.addEventListener('mousedown', function (e) {
|
|
dragging = true;
|
|
resizer.classList.add('is-dragging');
|
|
startX = e.clientX;
|
|
startWidth = treePane.getBoundingClientRect().width;
|
|
e.preventDefault();
|
|
});
|
|
document.addEventListener('mousemove', function (e) {
|
|
if (!dragging) return;
|
|
var dx = e.clientX - startX;
|
|
var w = Math.max(180, Math.min(window.innerWidth * 0.6, startWidth + dx));
|
|
treePane.style.width = w + 'px';
|
|
});
|
|
document.addEventListener('mouseup', function () {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
resizer.classList.remove('is-dragging');
|
|
});
|
|
}
|
|
|
|
// Tree-row clicks (event delegation on the tree body).
|
|
// Click semantics on a folder row:
|
|
// - plain click → toggle expand (deferred so dblclick wins)
|
|
// - shift-click → recursive expand/collapse of the subtree
|
|
// - alt-click → ALSO recursive
|
|
// - dblclick → navigate into the folder
|
|
// File rows: plain click → preview in right pane; modifier-click
|
|
// and middle-click open in new tab.
|
|
//
|
|
// The plain-click toggle for folders is intentionally deferred
|
|
// via setTimeout. Reason: toggling re-renders the tree, which
|
|
// replaces the clicked row element. The browser detects a
|
|
// double-click only when the second click lands on the same
|
|
// target element as the first; replacing the row breaks that
|
|
// continuity and the dblclick event never fires. The deferred
|
|
// toggle lets a pending dblclick cancel it.
|
|
var pendingFolderToggle = null;
|
|
var treeBody = document.getElementById('treeBody');
|
|
if (treeBody) {
|
|
treeBody.addEventListener('click', function (e) {
|
|
var row = e.target.closest('.tree-row');
|
|
if (!row) return;
|
|
var id = parseInt(row.dataset.id, 10);
|
|
var node = state.nodes.get(id);
|
|
if (!node) return;
|
|
|
|
var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';
|
|
|
|
if (isExpandable) {
|
|
e.preventDefault();
|
|
if (e.shiftKey || e.altKey) {
|
|
// Modifier-click skips the dblclick race — it's
|
|
// an explicit recursive toggle, never followed
|
|
// by a dblclick.
|
|
if (node.expanded) tree.collapseSubtree(id);
|
|
else tree.expandSubtree(id);
|
|
return;
|
|
}
|
|
// ZIPs don't navigate-into; toggle immediately.
|
|
if (row.dataset.iszip === 'true') {
|
|
tree.toggleFolder(id);
|
|
return;
|
|
}
|
|
// Folder: defer the toggle so a pending dblclick
|
|
// can pre-empt it.
|
|
if (pendingFolderToggle) {
|
|
clearTimeout(pendingFolderToggle.timer);
|
|
}
|
|
pendingFolderToggle = {
|
|
id: id,
|
|
timer: setTimeout(function () {
|
|
pendingFolderToggle = null;
|
|
tree.toggleFolder(id);
|
|
}, 220)
|
|
};
|
|
return;
|
|
}
|
|
|
|
// File row: modifier-click → open URL in new tab if
|
|
// available (server mode preserves the original URL,
|
|
// useful for direct download / sharing).
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) {
|
|
if (node.url) window.open(node.url, '_blank', 'noopener');
|
|
return;
|
|
}
|
|
// Plain click → preview in the right pane.
|
|
e.preventDefault();
|
|
state.selectedId = id;
|
|
state.lastPreviewedNodeId = id;
|
|
tree.render(); // refresh selection highlight
|
|
var p = previewMod();
|
|
if (p) p.showFilePreview(node);
|
|
});
|
|
|
|
// Double-click on a folder → "navigate into" it. Distinct
|
|
// from single-click (which expands inline) so users keep
|
|
// both UX models. Server mode jumps to the folder URL —
|
|
// zddc-server returns a fresh browse instance scoped to
|
|
// that directory. FS-API mode swaps state.rootHandle to
|
|
// the folder's handle and re-loads, so the user sees
|
|
// only that subtree at the root level.
|
|
//
|
|
// Files: dblclick is left alone — the single-click preview
|
|
// is already a "look at this file" action; a separate
|
|
// navigate-into doesn't apply.
|
|
// ZIPs: skipped too — they're inspected via inline
|
|
// expansion (JSZip), not navigated into.
|
|
treeBody.addEventListener('dblclick', function (e) {
|
|
var row = e.target.closest('.tree-row');
|
|
if (!row) return;
|
|
if (row.dataset.isdir !== 'true') return;
|
|
var id = parseInt(row.dataset.id, 10);
|
|
var node = state.nodes.get(id);
|
|
if (!node) return;
|
|
e.preventDefault();
|
|
// Pre-empt the deferred single-click toggle so the user
|
|
// doesn't see a flicker of expand/collapse before nav.
|
|
if (pendingFolderToggle) {
|
|
clearTimeout(pendingFolderToggle.timer);
|
|
pendingFolderToggle = null;
|
|
}
|
|
navigateIntoFolder(node);
|
|
});
|
|
|
|
// Right-click → context menu. Two surfaces:
|
|
// - on a tree row: per-row menu (Open, Rename, Delete, …)
|
|
// - on empty space in the pane: directory-scope menu
|
|
// (New folder, Refresh, Sort by, …)
|
|
treeBody.addEventListener('contextmenu', function (e) {
|
|
e.preventDefault();
|
|
var row = e.target.closest('.tree-row');
|
|
if (row) {
|
|
var id = parseInt(row.dataset.id, 10);
|
|
var node = state.nodes.get(id);
|
|
if (!node) return;
|
|
state.selectedId = id;
|
|
tree.render();
|
|
window.zddc.menu.open({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
context: { node: node, row: row },
|
|
items: buildTreeRowMenu
|
|
});
|
|
} else {
|
|
window.zddc.menu.open({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
context: { dir: state.currentPath || '/' },
|
|
items: buildPaneMenu
|
|
});
|
|
}
|
|
});
|
|
|
|
// Per-row drag-drop. Any row is a drop target — folders
|
|
// upload into themselves; files upload into their parent
|
|
// folder. Highlighting is purely visual; server-side ACL
|
|
// is the source of truth (a 403 surfaces as an error toast).
|
|
wirePerRowDrop(treeBody);
|
|
}
|
|
}
|
|
|
|
// ── Per-row drag/drop targets ─────────────────────────────────────────
|
|
|
|
// Translate a node into the directory that should receive uploads
|
|
// dropped onto its row. Folders → themselves; files → their parent.
|
|
// Returns a server path with a trailing slash, or null when there's
|
|
// no usable destination (offline mode, virtual node, etc.).
|
|
function targetDirForNode(node) {
|
|
if (!node || node.virtual) return null;
|
|
if (state.source !== 'server') return null;
|
|
if (node.isZip) return null; // can't upload INTO a zip via PUT
|
|
var dirNode = node;
|
|
if (!node.isDir) {
|
|
if (node.parentId == null) {
|
|
// Top-level file → upload to current scope.
|
|
return state.currentPath || '/';
|
|
}
|
|
dirNode = state.nodes.get(node.parentId);
|
|
if (!dirNode) return null;
|
|
}
|
|
var p = tree.pathFor(dirNode);
|
|
if (!p.endsWith('/')) p += '/';
|
|
return p;
|
|
}
|
|
|
|
// True when this node is a file viewed through the synthetic
|
|
// <workflow>/received/ window — the URL has a `received/` segment
|
|
// that's NOT preceded by `archive/<party>/` (the canonical record
|
|
// form). A drop here is a review-comment intent: server rewrites to
|
|
// <workflow>/<base>+C<n><suffix>.
|
|
function isVirtualReceivedFile(node) {
|
|
if (!node || node.isDir || state.source !== 'server') return false;
|
|
var url = tree.pathFor(node);
|
|
var parts = url.replace(/^\/+/, '').split('/');
|
|
var idx = parts.indexOf('received');
|
|
if (idx < 2) return false;
|
|
// Canonical form: parts[idx - 2] === 'archive'. Virtual form: anything else.
|
|
return parts[idx - 2].toLowerCase() !== 'archive';
|
|
}
|
|
|
|
function dragHasFiles(e) {
|
|
if (!e.dataTransfer || !e.dataTransfer.types) return false;
|
|
var types = e.dataTransfer.types;
|
|
for (var i = 0; i < types.length; i++) {
|
|
if (types[i] === 'Files') return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function wirePerRowDrop(treeBody) {
|
|
var lastOver = null;
|
|
function clearHighlight() {
|
|
if (lastOver) {
|
|
lastOver.classList.remove('is-droptarget');
|
|
lastOver = null;
|
|
}
|
|
}
|
|
treeBody.addEventListener('dragover', function (e) {
|
|
if (!dragHasFiles(e)) return;
|
|
var row = e.target.closest('.tree-row');
|
|
if (!row) { clearHighlight(); return; }
|
|
var id = parseInt(row.dataset.id, 10);
|
|
var node = state.nodes.get(id);
|
|
if (!node) return;
|
|
var dest = targetDirForNode(node);
|
|
if (!dest) {
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'none';
|
|
clearHighlight();
|
|
return;
|
|
}
|
|
e.preventDefault(); // signals "this is a drop target"
|
|
e.stopPropagation(); // suppress doc-level overlay
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
if (lastOver !== row) {
|
|
clearHighlight();
|
|
row.classList.add('is-droptarget');
|
|
lastOver = row;
|
|
}
|
|
});
|
|
treeBody.addEventListener('dragleave', function (e) {
|
|
// dragleave fires on row crossings too — only clear when the
|
|
// pointer actually leaves the tree body.
|
|
if (!e.relatedTarget || !treeBody.contains(e.relatedTarget)) {
|
|
clearHighlight();
|
|
}
|
|
});
|
|
treeBody.addEventListener('drop', async function (e) {
|
|
if (!dragHasFiles(e)) return;
|
|
var row = e.target.closest('.tree-row');
|
|
clearHighlight();
|
|
if (!row) return;
|
|
var id = parseInt(row.dataset.id, 10);
|
|
var node = state.nodes.get(id);
|
|
if (!node) return;
|
|
// Comment-upload short-circuit: drop on a file that lives
|
|
// under the virtual <workflow>/received/ window is a "comment
|
|
// on this file" intent. PUT to the target's URL — the server
|
|
// rewrites to <workflow>/<base>+C<n><suffix> and the canonical
|
|
// record (WORM) stays untouched. Confirm first so the user
|
|
// sees what's about to happen.
|
|
if (!node.isDir && isVirtualReceivedFile(node)) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!window.confirm("Drop bytes here as a review comment on '" + node.name + "'? The server will save it in the workflow folder with a +C<n> revision modifier.")) {
|
|
return;
|
|
}
|
|
var upMod = window.app.modules.upload;
|
|
if (!upMod) return;
|
|
var targetURL = tree.pathFor(node);
|
|
try {
|
|
await upMod.uploadCommentToTarget(targetURL, e.dataTransfer);
|
|
} catch (err) {
|
|
statusError('Comment upload failed: ' + (err.message || err));
|
|
}
|
|
return;
|
|
}
|
|
var dest = targetDirForNode(node);
|
|
if (!dest) return;
|
|
e.preventDefault();
|
|
e.stopPropagation(); // pre-empt doc-level handler
|
|
var up = window.app.modules.upload;
|
|
if (!up) return;
|
|
try {
|
|
await up.uploadToDir(dest, e.dataTransfer);
|
|
} catch (err) {
|
|
statusError('Upload failed: ' + (err.message || err));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Create new folder / file (server mode) ────────────────────────────
|
|
|
|
// Reject names with path separators, leading dots, or empty input —
|
|
// mirrors the server-side hidden-segment / no-traversal guards so
|
|
// the user sees the rejection without a round-trip.
|
|
function validateName(name) {
|
|
name = (name || '').trim();
|
|
if (!name) return { ok: false, msg: 'Name required.' };
|
|
if (name.indexOf('/') !== -1) return { ok: false, msg: 'No slashes allowed.' };
|
|
if (name === '.' || name === '..') return { ok: false, msg: 'Invalid name.' };
|
|
if (name.charAt(0) === '.' || name.charAt(0) === '_') {
|
|
return { ok: false, msg: 'Names beginning with "." or "_" are reserved.' };
|
|
}
|
|
return { ok: true, name: name };
|
|
}
|
|
|
|
// Resolve "the directory new items go into" for a given row.
|
|
// Folders/zips: create inside them. Files: create alongside (in
|
|
// their parent). Used by the row-context New menu items.
|
|
function parentDirFor(node) {
|
|
var parentDir;
|
|
if (!node) {
|
|
parentDir = state.currentPath || '/';
|
|
} else if (node.isDir || node.isZip) {
|
|
parentDir = tree.pathFor(node);
|
|
} else if (node.parentId != null) {
|
|
var parent = state.nodes.get(node.parentId);
|
|
parentDir = parent ? tree.pathFor(parent) : (state.currentPath || '/');
|
|
} else {
|
|
parentDir = state.currentPath || '/';
|
|
}
|
|
if (!parentDir.endsWith('/')) parentDir += '/';
|
|
return parentDir;
|
|
}
|
|
|
|
async function createInDir(parentDir, kind) {
|
|
var up = window.app.modules.upload;
|
|
if (!up) return;
|
|
var promptMsg = kind === 'folder'
|
|
? 'New folder name (under ' + parentDir + '):'
|
|
: 'New markdown filename (under ' + parentDir + '):';
|
|
var defaultName = kind === 'folder' ? 'new-folder' : 'new.md';
|
|
var raw = window.prompt(promptMsg, defaultName);
|
|
if (raw == null) return;
|
|
var v = validateName(raw);
|
|
if (!v.ok) {
|
|
statusError(v.msg);
|
|
return;
|
|
}
|
|
try {
|
|
if (kind === 'folder') {
|
|
await up.makeDir(parentDir, v.name);
|
|
statusInfo('Created folder ' + v.name);
|
|
} else {
|
|
var name = /\.(md|markdown)$/i.test(v.name) ? v.name : v.name + '.md';
|
|
var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
|
|
await up.makeFile(parentDir, name, template, 'text/markdown; charset=utf-8');
|
|
statusInfo('Created ' + name);
|
|
}
|
|
await reloadDir(parentDir);
|
|
} catch (e) {
|
|
statusError('Create failed: ' + (e.message || e));
|
|
}
|
|
}
|
|
|
|
function createInside(node, kind) { return createInDir(parentDirFor(node), kind); }
|
|
|
|
// Reload a directory's children in the tree so a create/delete/
|
|
// rename is reflected. Works for both the current scope (root)
|
|
// and any expanded subdirectory.
|
|
async function reloadDir(dirPath) {
|
|
var loader = window.app.modules.loader;
|
|
if (!loader) return;
|
|
if (!dirPath.endsWith('/')) dirPath += '/';
|
|
// Root-scope reload — refresh the visible top-level listing.
|
|
if (dirPath === state.currentPath) {
|
|
try {
|
|
var es = state.source === 'server'
|
|
? await loader.fetchServerChildren(dirPath)
|
|
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
|
|
tree.setRoot(es);
|
|
} catch (_e) { /* swallow */ }
|
|
tree.render();
|
|
return;
|
|
}
|
|
// Otherwise find the node whose path matches and reload it.
|
|
var noSlash = dirPath.replace(/\/$/, '');
|
|
var hit = null;
|
|
state.nodes.forEach(function (n) {
|
|
if (hit || !n.isDir) return;
|
|
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
|
|
});
|
|
if (hit) {
|
|
try {
|
|
var raw = state.source === 'server'
|
|
? await loader.fetchServerChildren(dirPath)
|
|
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
|
|
tree.setChildren(hit.id, raw);
|
|
hit.expanded = true;
|
|
} catch (_e) { /* swallow */ }
|
|
tree.render();
|
|
}
|
|
}
|
|
|
|
// ── Rename / Delete ───────────────────────────────────────────────────
|
|
|
|
async function renameNode(node) {
|
|
var up = window.app.modules.upload;
|
|
if (!up || !up.canMutate(node)) return;
|
|
var raw = window.prompt('Rename "' + node.name + '" to:', node.name);
|
|
if (raw == null) return;
|
|
var v = validateName(raw);
|
|
if (!v.ok) { statusError(v.msg); return; }
|
|
if (v.name === node.name) return;
|
|
try {
|
|
await up.renameNode(node, v.name);
|
|
statusInfo('Renamed to ' + v.name);
|
|
var parentPath = node.parentId != null
|
|
? tree.pathFor(state.nodes.get(node.parentId))
|
|
: (state.currentPath || '/');
|
|
await reloadDir(parentPath);
|
|
} catch (e) {
|
|
statusError('Rename failed: ' + (e.message || e));
|
|
}
|
|
}
|
|
|
|
async function deleteNode(node) {
|
|
var up = window.app.modules.upload;
|
|
if (!up || !up.canMutate(node)) return;
|
|
var what = node.isDir ? 'folder' : 'file';
|
|
// Native confirm() is intentional — destructive actions
|
|
// benefit from the browser's blocking, OS-styled dialog
|
|
// (signals "this is serious"). A custom modal would look
|
|
// friendlier; we want it to NOT look friendly.
|
|
var msg = 'Permanently delete this ' + what + '?\n\n' + node.name;
|
|
if (node.isDir) {
|
|
msg += '\n\nThis will remove every file inside it.';
|
|
}
|
|
if (!window.confirm(msg)) return;
|
|
try {
|
|
await up.removeNode(node);
|
|
statusInfo('Deleted ' + node.name);
|
|
// Clear selection / preview when they pointed at the
|
|
// now-gone node, so the right pane doesn't keep a ghost.
|
|
if (state.selectedId === node.id) state.selectedId = null;
|
|
if (state.lastPreviewedNodeId === node.id) {
|
|
state.lastPreviewedNodeId = null;
|
|
var pb = document.getElementById('previewBody');
|
|
if (pb) pb.innerHTML =
|
|
'<div class="preview-empty">Click a file in the tree to preview it.</div>';
|
|
var pt = document.getElementById('previewTitle');
|
|
if (pt) pt.textContent = 'No file selected';
|
|
var pm = document.getElementById('previewMeta');
|
|
if (pm) pm.textContent = '';
|
|
}
|
|
var parentPath = node.parentId != null
|
|
? tree.pathFor(state.nodes.get(node.parentId))
|
|
: (state.currentPath || '/');
|
|
await reloadDir(parentPath);
|
|
} catch (e) {
|
|
statusError('Delete failed: ' + (e.message || e));
|
|
}
|
|
}
|
|
|
|
// Shared submenu (used by both the row menu and the pane menu).
|
|
// Toggle items so the active sort is checked in both surfaces.
|
|
var SORT_BY_ITEMS = [
|
|
{ label: 'Name',
|
|
checked: function () { return state.sort.key === 'name'; },
|
|
action: function () { tree.setSortExplicit('name', 1); } },
|
|
{ label: 'Modified',
|
|
checked: function () { return state.sort.key === 'date'; },
|
|
action: function () { tree.setSortExplicit('date', -1); } },
|
|
{ label: 'Size',
|
|
checked: function () { return state.sort.key === 'size'; },
|
|
action: function () { tree.setSortExplicit('size', -1); } },
|
|
{ label: 'Type',
|
|
checked: function () { return state.sort.key === 'ext'; },
|
|
action: function () { tree.setSortExplicit('ext', 1); } }
|
|
];
|
|
|
|
// Row context menu — traditional file-manager layout:
|
|
// Open / Open in new tab / Pop out preview
|
|
// ─
|
|
// Download (label flips on type)
|
|
// ─
|
|
// New folder / New markdown file
|
|
// ─
|
|
// Rename / Delete (permission-gated, disabled
|
|
// when the row can't be mutated)
|
|
// ─
|
|
// Copy path / Copy name
|
|
// ─
|
|
// Expand / Collapse / Navigate into
|
|
// ─
|
|
// Sort by … / Show hidden files
|
|
//
|
|
// Items are kept VISIBLE but DISABLED when they don't apply, so
|
|
// every menu has the same shape regardless of what the user
|
|
// right-clicked. Predictable position = muscle memory.
|
|
function buildTreeRowMenu(ctx) {
|
|
var serverMode = state.source === 'server';
|
|
var canMutate = function (c) {
|
|
var up = window.app.modules.upload;
|
|
return !!(up && up.canMutate(c.node));
|
|
};
|
|
return [
|
|
// ── Open / preview cluster ──
|
|
{
|
|
label: function (c) {
|
|
if (c.node.isDir) return 'Open';
|
|
if (c.node.isZip) return 'Open archive';
|
|
return 'Preview';
|
|
},
|
|
disabled: function (c) { return !!c.node.virtual; },
|
|
action: function (c) {
|
|
if (c.node.isDir || c.node.isZip) {
|
|
tree.toggleFolder(c.node.id);
|
|
} else {
|
|
var p = previewMod();
|
|
if (p) p.showFilePreview(c.node);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
label: 'Open in new tab',
|
|
accel: 'Ctrl+Click',
|
|
disabled: function (c) { return !c.node.url; },
|
|
action: function (c) {
|
|
if (c.node.url) window.open(c.node.url, '_blank', 'noopener');
|
|
}
|
|
},
|
|
{
|
|
label: 'Pop out preview',
|
|
disabled: function (c) { return c.node.isDir || c.node.isZip; },
|
|
action: function (c) {
|
|
var p = previewMod();
|
|
if (p) p.showFilePreview(c.node, { popup: true });
|
|
}
|
|
},
|
|
{ separator: true },
|
|
|
|
// ── Download (single item; label flips on type) ──
|
|
{
|
|
label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; },
|
|
icon: '⤓',
|
|
disabled: function (c) { return !!c.node.virtual; },
|
|
action: function (c) {
|
|
var d = window.app.modules.download;
|
|
if (!d) return;
|
|
if (c.node.isDir) d.downloadFolder(c.node);
|
|
else d.downloadFile(c.node);
|
|
}
|
|
},
|
|
{ separator: true },
|
|
|
|
// ── Create new (in the row's parent folder) ──
|
|
{
|
|
label: 'New folder',
|
|
disabled: !serverMode,
|
|
action: function (c) { createInside(c.node, 'folder'); }
|
|
},
|
|
{
|
|
label: 'New markdown file',
|
|
disabled: !serverMode,
|
|
action: function (c) { createInside(c.node, 'markdown'); }
|
|
},
|
|
{ separator: true },
|
|
|
|
// ── Rename + Delete (the permission-gated pair) ──
|
|
{
|
|
label: 'Rename…',
|
|
disabled: function (c) { return !canMutate(c); },
|
|
action: function (c) { renameNode(c.node); }
|
|
},
|
|
{
|
|
label: 'Delete…',
|
|
icon: '🗑',
|
|
danger: true,
|
|
disabled: function (c) { return !canMutate(c); },
|
|
action: function (c) { deleteNode(c.node); }
|
|
},
|
|
{ separator: true },
|
|
|
|
// ── Clipboard / identifiers ──
|
|
{
|
|
label: 'Copy path',
|
|
action: function (c) {
|
|
var path = tree.pathFor(c.node);
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(path).then(
|
|
function () { statusInfo('Copied: ' + path); },
|
|
function () { statusError('Clipboard copy denied'); }
|
|
);
|
|
} else {
|
|
statusInfo(path);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
label: 'Copy name',
|
|
action: function (c) {
|
|
// Always include the file extension. node.name
|
|
// already does for normal listings, but re-joining
|
|
// via zddc.joinExtension is defensive against any
|
|
// upstream that ever returns the basename split.
|
|
var n = c.node.name;
|
|
var ext = c.node.ext;
|
|
if (!c.node.isDir && ext
|
|
&& !n.toLowerCase().endsWith('.' + ext.toLowerCase())) {
|
|
n = window.zddc.joinExtension(n, ext);
|
|
}
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(n);
|
|
}
|
|
statusInfo('Copied: ' + n);
|
|
}
|
|
},
|
|
{ separator: true },
|
|
|
|
// ── Tree-view ops (folder/zip rows only) ──
|
|
{
|
|
label: 'Expand subtree',
|
|
accel: 'Shift+Click',
|
|
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
|
|
action: function (c) { tree.expandSubtree(c.node.id); }
|
|
},
|
|
{
|
|
label: 'Collapse subtree',
|
|
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
|
|
action: function (c) { tree.collapseSubtree(c.node.id); }
|
|
},
|
|
{
|
|
label: 'Navigate into',
|
|
accel: 'Dbl-click',
|
|
disabled: function (c) { return !c.node.isDir; },
|
|
action: function (c) { navigateIntoFolder(c.node); }
|
|
},
|
|
{ separator: true },
|
|
|
|
// ── Plan Review (received/<tracking>/ only, cascade-gated) ──
|
|
{
|
|
label: 'Plan Review…',
|
|
visible: function (c) {
|
|
if (!serverMode) return false;
|
|
if (!state.scopeOnPlanReview) return false;
|
|
var pr = window.app.modules.planReview;
|
|
if (!pr) return false;
|
|
return pr.isReceivedTrackingFolder(c.node);
|
|
},
|
|
action: function (c) {
|
|
var pr = window.app.modules.planReview;
|
|
if (pr) pr.invoke(c.node);
|
|
}
|
|
},
|
|
// ── Accept Transmittal (transmittal folder under incoming/) ──
|
|
{
|
|
label: 'Accept Transmittal…',
|
|
visible: function (c) {
|
|
if (!serverMode) return false;
|
|
var at = window.app.modules.acceptTransmittal;
|
|
if (!at) return false;
|
|
return at.isAcceptableTransmittalFolder(c.node);
|
|
},
|
|
action: function (c) {
|
|
var at = window.app.modules.acceptTransmittal;
|
|
if (at) at.invoke(c.node);
|
|
}
|
|
},
|
|
// ── Stage / Unstage (files under working/ or staging/) ──
|
|
{
|
|
label: 'Stage to…',
|
|
visible: function (c) {
|
|
if (!serverMode) return false;
|
|
var s = window.app.modules.stage;
|
|
return !!(s && s.isStageableFile(c.node));
|
|
},
|
|
action: function (c) {
|
|
var s = window.app.modules.stage;
|
|
if (s) s.invokeStage(c.node);
|
|
}
|
|
},
|
|
{
|
|
label: 'Unstage to working/',
|
|
visible: function (c) {
|
|
if (!serverMode) return false;
|
|
var s = window.app.modules.stage;
|
|
return !!(s && s.isUnstageableFile(c.node));
|
|
},
|
|
action: function (c) {
|
|
var s = window.app.modules.stage;
|
|
if (s) s.invokeUnstage(c.node);
|
|
}
|
|
},
|
|
{ separator: true },
|
|
|
|
// ── View ──
|
|
{ label: 'Sort by', items: SORT_BY_ITEMS },
|
|
{ label: 'Show hidden files',
|
|
checked: function () { return !!state.showHidden; },
|
|
action: function () {
|
|
state.showHidden = !state.showHidden;
|
|
refreshListing();
|
|
} }
|
|
];
|
|
}
|
|
|
|
// Right-click on empty space in the tree pane → directory-scope
|
|
// menu. Operations apply to the current scope (state.currentPath),
|
|
// not any specific row.
|
|
function buildPaneMenu() {
|
|
var serverMode = state.source === 'server';
|
|
return [
|
|
{
|
|
label: 'New folder',
|
|
disabled: !serverMode,
|
|
action: function () { createInDir(state.currentPath || '/', 'folder'); }
|
|
},
|
|
{
|
|
label: 'New markdown file',
|
|
disabled: !serverMode,
|
|
action: function () { createInDir(state.currentPath || '/', 'markdown'); }
|
|
},
|
|
// ── Create Transmittal folder (staging/ scope only) ──
|
|
{
|
|
label: 'Create Transmittal folder…',
|
|
visible: function () {
|
|
return serverMode && state.scopeCanonicalFolder === 'staging';
|
|
},
|
|
action: function () {
|
|
var ct = window.app.modules.createTransmittal;
|
|
if (ct) ct.invoke();
|
|
}
|
|
},
|
|
{ separator: true },
|
|
{
|
|
label: 'Refresh',
|
|
accel: 'F5',
|
|
action: function () { refreshListing(); }
|
|
},
|
|
{ separator: true },
|
|
{ label: 'Sort by', items: SORT_BY_ITEMS },
|
|
{ label: 'Show hidden files',
|
|
checked: function () { return !!state.showHidden; },
|
|
action: function () {
|
|
state.showHidden = !state.showHidden;
|
|
refreshListing();
|
|
} }
|
|
];
|
|
}
|
|
|
|
// View mode is URL-driven, not UI-driven.
|
|
//
|
|
// ?view=grid → grid mode (only honored where classifier is
|
|
// available; otherwise falls back to browse)
|
|
// ?view=browse → browse mode (always)
|
|
// default → path-based: grid when inside an incoming/
|
|
// subtree, browse everywhere else
|
|
//
|
|
// resolveViewMode reads the current location and returns the mode
|
|
// to render; applyResolvedViewMode toggles the panes accordingly.
|
|
// Called on initial load and on every client-side rescope.
|
|
function resolveViewMode() {
|
|
var qs = new URLSearchParams(window.location.search);
|
|
var explicit = (qs.get('view') || '').toLowerCase();
|
|
var grid = window.app.modules.grid;
|
|
var classifierHere = !!(grid && grid.availableHere && grid.availableHere());
|
|
if (explicit === 'grid') return classifierHere ? 'grid' : 'browse';
|
|
if (explicit === 'browse') return 'browse';
|
|
return classifierHere ? 'grid' : 'browse';
|
|
}
|
|
|
|
function applyResolvedViewMode() {
|
|
var mode = resolveViewMode();
|
|
state.viewMode = mode;
|
|
var browseView = document.getElementById('browseView');
|
|
var gridView = document.getElementById('gridView');
|
|
if (mode === 'grid') {
|
|
if (browseView) browseView.classList.add('hidden');
|
|
if (gridView) gridView.classList.remove('hidden');
|
|
var grid = window.app.modules.grid;
|
|
if (grid) {
|
|
if (grid.reset) grid.reset();
|
|
if (grid.activate) grid.activate();
|
|
}
|
|
} else {
|
|
if (browseView) browseView.classList.remove('hidden');
|
|
if (gridView) gridView.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
async function navigateIntoFolder(node) {
|
|
if (state.source === 'server') {
|
|
// Rescope client-side rather than hard-navigating. A hard
|
|
// nav would let zddc-server's auto-serve kick in and swap
|
|
// us out of browse for canonical folders (e.g. /archive/
|
|
// → archive tool, /staging/ → transmittal). Staying in
|
|
// browse is what the user asked for; pushState keeps the
|
|
// URL bar accurate so a reload would re-load browse at the
|
|
// new scope.
|
|
var url = window.app.modules.tree.pathFor(node);
|
|
if (!url.endsWith('/')) url += '/';
|
|
await rescopeServer(url, node.name);
|
|
return;
|
|
}
|
|
if (state.source === 'fs') {
|
|
if (!node.handle || node.handle.kind !== 'directory') return;
|
|
state.rootHandle = node.handle;
|
|
state.currentPath = node.handle.name + '/';
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchFsChildren(node.handle);
|
|
} catch (e) {
|
|
statusError('Failed to enter ' + node.name + ': ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
tree.render();
|
|
statusInfo('Entered ' + node.name);
|
|
}
|
|
}
|
|
|
|
// Client-side rescope for server mode. Updates the URL via
|
|
// history.pushState, fetches the new directory listing, and
|
|
// re-renders the tree from scratch. Page DOES NOT reload.
|
|
async function rescopeServer(url, displayName) {
|
|
var entries;
|
|
try {
|
|
entries = await loader.fetchServerChildren(url);
|
|
} catch (e) {
|
|
statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
|
|
return;
|
|
}
|
|
state.currentPath = url;
|
|
// Selection / preview belong to the old scope; clear them so
|
|
// the new root doesn't carry stale highlight state.
|
|
state.selectedId = null;
|
|
state.lastPreviewedNodeId = null;
|
|
// Virtual canonical folders are emitted by zddc-server itself
|
|
// (so .zddc display: overrides apply uniformly); no client-side
|
|
// merge needed.
|
|
tree.setRoot(entries);
|
|
tree.render();
|
|
// Reset the preview pane so the user sees an "empty selection"
|
|
// state at the new scope instead of the previous file.
|
|
var previewBody = document.getElementById('previewBody');
|
|
if (previewBody) previewBody.innerHTML = '';
|
|
var previewTitle = document.getElementById('previewTitle');
|
|
if (previewTitle) previewTitle.textContent = 'No file selected';
|
|
var previewMeta = document.getElementById('previewMeta');
|
|
if (previewMeta) previewMeta.textContent = '';
|
|
// pushState so the URL bar reflects the new scope. A real
|
|
// reload would re-load browse at this URL (trailing slash →
|
|
// ServeDirectory → embedded browse SPA).
|
|
try {
|
|
history.pushState({ zddcBrowse: true, path: url }, '', url);
|
|
} catch (_e) { /* private browsing edge cases */ }
|
|
statusInfo('Entered ' + displayName);
|
|
// The new scope may have a different default view (grid inside
|
|
// incoming/, browse elsewhere). Re-resolve from the URL now
|
|
// that pushState has updated it.
|
|
applyResolvedViewMode();
|
|
}
|
|
|
|
// Public API
|
|
window.app.modules.events = {
|
|
init: init,
|
|
statusError: statusError,
|
|
statusInfo: statusInfo,
|
|
statusClear: statusClear,
|
|
showBrowseRoot: showBrowseRoot,
|
|
applyResolvedViewMode: applyResolvedViewMode
|
|
};
|
|
})();
|