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:
parent
436e8ca066
commit
319a3c0ce7
4 changed files with 109 additions and 6 deletions
|
|
@ -313,6 +313,22 @@ html, body {
|
||||||
font-weight: 500;
|
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 (Phase C) ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.grid-view {
|
.grid-view {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,47 @@
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
var events = window.app.modules.events;
|
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() {
|
async function bootstrap() {
|
||||||
events.init();
|
events.init();
|
||||||
|
|
||||||
|
|
@ -17,7 +58,8 @@
|
||||||
// state with the "Select Directory" button.
|
// state with the "Select Directory" button.
|
||||||
var detected = await loader.autoDetectServerMode();
|
var detected = await loader.autoDetectServerMode();
|
||||||
if (detected) {
|
if (detected) {
|
||||||
tree.setRoot(detected.entries);
|
var entries = withVirtualCanonicals(detected.entries, detected.path);
|
||||||
|
tree.setRoot(entries);
|
||||||
events.showBrowseRoot();
|
events.showBrowseRoot();
|
||||||
tree.render();
|
tree.render();
|
||||||
events.statusInfo('Loaded ' + detected.entries.length + ' item'
|
events.statusInfo('Loaded ' + detected.entries.length + ' item'
|
||||||
|
|
|
||||||
|
|
@ -183,11 +183,21 @@
|
||||||
|
|
||||||
// Tree-row clicks (event delegation on the tree body).
|
// Tree-row clicks (event delegation on the tree body).
|
||||||
// Click semantics on a folder row:
|
// 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
|
// - shift-click → recursive expand/collapse of the subtree
|
||||||
// - alt-click → ALSO recursive
|
// - alt-click → ALSO recursive
|
||||||
|
// - dblclick → navigate into the folder
|
||||||
// File rows: plain click → preview in right pane; modifier-click
|
// File rows: plain click → preview in right pane; modifier-click
|
||||||
// and middle-click open in new tab.
|
// 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');
|
var treeBody = document.getElementById('treeBody');
|
||||||
if (treeBody) {
|
if (treeBody) {
|
||||||
treeBody.addEventListener('click', function (e) {
|
treeBody.addEventListener('click', function (e) {
|
||||||
|
|
@ -202,11 +212,30 @@
|
||||||
if (isExpandable) {
|
if (isExpandable) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.shiftKey || e.altKey) {
|
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);
|
if (node.expanded) tree.collapseSubtree(id);
|
||||||
else tree.expandSubtree(id);
|
else tree.expandSubtree(id);
|
||||||
} else {
|
return;
|
||||||
tree.toggleFolder(id);
|
|
||||||
}
|
}
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,6 +276,12 @@
|
||||||
var node = state.nodes.get(id);
|
var node = state.nodes.get(id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
e.preventDefault();
|
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);
|
navigateIntoFolder(node);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,11 @@
|
||||||
isZip: isZip,
|
isZip: isZip,
|
||||||
zipFile: null, // cached JSZip instance
|
zipFile: null, // cached JSZip instance
|
||||||
zipPath: raw.zipPath || null, // path within zip (for virtual children)
|
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);
|
state.nodes.set(id, node);
|
||||||
return node;
|
return node;
|
||||||
|
|
@ -159,17 +163,23 @@
|
||||||
var chevronClass = 'tree-name__chevron'
|
var chevronClass = 'tree-name__chevron'
|
||||||
+ (expandable ? '' : ' tree-name__chevron--leaf');
|
+ (expandable ? '' : ' tree-name__chevron--leaf');
|
||||||
var selected = state.selectedId === node.id ? ' is-selected' : '';
|
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 ''
|
return ''
|
||||||
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected
|
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected + virtualCls
|
||||||
+ '" data-id="' + node.id
|
+ '" data-id="' + node.id
|
||||||
+ '" data-isdir="' + node.isDir
|
+ '" data-isdir="' + node.isDir
|
||||||
+ '" data-iszip="' + node.isZip + '"'
|
+ '" data-iszip="' + node.isZip + '"'
|
||||||
|
+ (node.virtual ? ' data-virtual="true"' : '')
|
||||||
+ ' style="padding-left:' + indent + 'rem"'
|
+ ' style="padding-left:' + indent + 'rem"'
|
||||||
+ ' role="treeitem" tabindex="-1">'
|
+ ' role="treeitem" tabindex="-1">'
|
||||||
+ '<span class="' + chevronClass + '"></span>'
|
+ '<span class="' + chevronClass + '"></span>'
|
||||||
+ '<span class="tree-name__icon">' + iconChar + '</span>'
|
+ '<span class="tree-name__icon">' + iconChar + '</span>'
|
||||||
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
|
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
|
||||||
+ escapeHtml(node.name) + '</span>'
|
+ escapeHtml(node.name) + '</span>'
|
||||||
|
+ virtualHint
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue