fix(browse): dblclick navigates; show virtual canonical folders at project root

#5 — Double-click on a folder no longer toggles collapse.

Root cause: the single-click handler called tree.render() immediately,
which replaced the clicked row element. The browser's double-click
detection requires the second click to land on the SAME target as the
first, so dblclick never fired for folders.

Fix: defer the single-click toggle by 220ms. A pending dblclick within
the window cancels the toggle and runs navigateIntoFolder instead.
Modifier-clicks (shift/alt for recursive) and ZIP expands skip the
deferral — they're never followed by a dblclick navigation.

#3 — Browse at /<project>/ now always shows the four canonical
folders (archive, working, staging, reviewing) even when they don't
yet exist on disk. Each missing folder is synthesized client-side as
a "virtual" row: muted icon + label + "(empty)" hint, double-clickable
to navigate. zddc-server already serves an empty listing for these
paths (commit 3fc3717), so navigation into a virtual folder works
without 404 and the user lands in a sensible empty workspace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-11 11:23:14 -05:00
parent 436e8ca066
commit 319a3c0ce7
4 changed files with 109 additions and 6 deletions

View file

@ -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 {

View file

@ -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'

View file

@ -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);
});
}

View file

@ -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
? '<span class="tree-name__hint" title="Folder not yet created on disk — opens an empty workspace">(empty)</span>'
: '';
return ''
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected + virtualCls
+ '" data-id="' + node.id
+ '" data-isdir="' + node.isDir
+ '" data-iszip="' + node.isZip + '"'
+ (node.virtual ? ' data-virtual="true"' : '')
+ ' style="padding-left:' + indent + 'rem"'
+ ' role="treeitem" tabindex="-1">'
+ '<span class="' + chevronClass + '"></span>'
+ '<span class="tree-name__icon">' + iconChar + '</span>'
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
+ escapeHtml(node.name) + '</span>'
+ virtualHint
+ '</div>';
}