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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue