diff --git a/browse/css/tree.css b/browse/css/tree.css index 45785cf..e05690c 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -313,6 +313,22 @@ html, body { font-weight: 500; } +/* Virtual rows: synthesized client-side for folders that aren't on + disk yet (canonical project folders). Rendered muted so the user + reads them as "available but empty" rather than ordinary entries. + Hover/select states still apply; the hint sits to the right of the + label. */ +.tree-row--virtual .tree-name__icon, +.tree-row--virtual .tree-name__label { + opacity: 0.65; +} +.tree-name__hint { + margin-left: 0.5rem; + font-size: 0.78rem; + color: var(--text-muted); + font-style: italic; +} + /* ── Grid view (Phase C) ─────────────────────────────────────────────────── */ .grid-view { diff --git a/browse/js/app.js b/browse/js/app.js index 0b10936..cdd4198 100644 --- a/browse/js/app.js +++ b/browse/js/app.js @@ -8,6 +8,47 @@ var tree = window.app.modules.tree; var events = window.app.modules.events; + // Canonical folders that should appear at the root of a project + // view even if they don't yet exist on disk. Matches the four + // stage cards on the project landing page. zddc-server returns an + // empty listing for these paths (see commit 3fc3717), so + // navigating into a virtual folder works without 404. + var CANONICAL_PROJECT_FOLDERS = ['archive', 'working', 'staging', 'reviewing']; + + // Decide whether `path` looks like a project root — i.e. exactly + // one path segment after the leading slash. /Project-1/ → yes; + // / → no; /Project-1/working/ → no. + function isProjectRoot(path) { + if (!path || path === '/') return false; + var trimmed = path.replace(/^\/+|\/+$/g, ''); + if (!trimmed) return false; + return trimmed.indexOf('/') < 0; + } + + // Merge virtual entries for any canonical folders absent from the + // server's listing. Each virtual entry is shaped like a normal + // directory entry so the tree renderer treats it the same way. + function withVirtualCanonicals(entries, path) { + if (!isProjectRoot(path)) return entries; + var present = Object.create(null); + entries.forEach(function (e) { if (e.isDir) present[e.name] = true; }); + var augmented = entries.slice(); + CANONICAL_PROJECT_FOLDERS.forEach(function (name) { + if (!present[name]) { + augmented.push({ + name: name, + isDir: true, + size: 0, + modTime: null, + ext: '', + url: path.replace(/\/$/, '') + '/' + name + '/', + virtual: true + }); + } + }); + return augmented; + } + async function bootstrap() { events.init(); @@ -17,7 +58,8 @@ // state with the "Select Directory" button. var detected = await loader.autoDetectServerMode(); if (detected) { - tree.setRoot(detected.entries); + var entries = withVirtualCanonicals(detected.entries, detected.path); + tree.setRoot(entries); events.showBrowseRoot(); tree.render(); events.statusInfo('Loaded ' + detected.entries.length + ' item' diff --git a/browse/js/events.js b/browse/js/events.js index 23d4894..ec5816a 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -183,11 +183,21 @@ // Tree-row clicks (event delegation on the tree body). // Click semantics on a folder row: - // - plain click → toggle expand + // - 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) { @@ -202,11 +212,30 @@ 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); - } else { - tree.toggleFolder(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; } @@ -247,6 +276,12 @@ 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); }); } diff --git a/browse/js/tree.js b/browse/js/tree.js index a9f902a..e930ee4 100644 --- a/browse/js/tree.js +++ b/browse/js/tree.js @@ -35,7 +35,11 @@ isZip: isZip, zipFile: null, // cached JSZip instance zipPath: raw.zipPath || null, // path within zip (for virtual children) - zipParentId: raw.zipParentId || null // ancestor zip's node id (for nested entries) + zipParentId: raw.zipParentId || null, // ancestor zip's node id (for nested entries) + // True when this entry was synthesized client-side (e.g. + // canonical project folders that don't exist on disk yet). + // Rendered with a muted style + an "(empty)" hint. + virtual: !!raw.virtual }; state.nodes.set(id, node); return node; @@ -159,17 +163,23 @@ var chevronClass = 'tree-name__chevron' + (expandable ? '' : ' tree-name__chevron--leaf'); var selected = state.selectedId === node.id ? ' is-selected' : ''; + var virtualCls = node.virtual ? ' tree-row--virtual' : ''; + var virtualHint = node.virtual + ? '(empty)' + : ''; return '' - + '
'; }