User report: project root listings showed both "Archive" (PascalCase on
disk) and "archive (empty)" (lowercase virtual) — confusing duplicates.
This sweep:
1. Test fixture migrated to lowercase canonical folder names.
tests/data/test-archive.sh now creates archive/, received/, issued/
on disk. Three projects also get human-friendly .zddc titles
("Wabash Industrial Refit — Phase 1", etc.), and Project-3 carries
a display: override demonstrating the new map. Party names
(PartyA/B/C) stay unchanged — non-canonical.
2. New .zddc display: schema. Maps a child entry's on-disk name to a
human-friendly label. The on-disk name stays canonical (lowercase
for project-root folders); only the rendered label changes. Match
is case-insensitive. Example:
display:
archive: "Records"
working: "In-Progress"
No upward cascade — a parent .zddc doesn't relabel grand-children;
each directory sets display: on its own children.
3. listing.FileInfo gets a DisplayName field. fs.ListDirectory reads
the directory's .zddc display map and stamps DisplayName per entry.
The field is omitempty so listings without overrides stay
byte-identical to before.
4. Virtual canonical project-root folders (archive/working/staging/
reviewing) are now emitted by zddc-server (fs.ListDirectory) at any
project root where the on-disk variant is absent in any case. This
replaces the client-side injection in browse and lets the display:
map apply to virtual entries the same way it applies to real ones.
Browse drops its withVirtualCanonicals helper; the loader carries
display_name through from the server's listing.
5. Archive app project picker dropdown shows the .zddc title of each
project (sourced from ProjectInfo.Title in the server's project
list), falling back to the folder name when no title is set. When
they differ, the folder name is rendered in muted mono after the
title for traceability. data-name still carries the canonical
folder name so URL state stays stable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
539 lines
22 KiB
JavaScript
539 lines
22 KiB
JavaScript
// tree.js — in-memory tree model + DOM rendering.
|
|
//
|
|
// Nodes are stored flat in state.nodes (Map by id). The visible
|
|
// render is a depth-first walk starting from state.rootIds, skipping
|
|
// children of unexpanded folders. This decouples model from DOM and
|
|
// keeps re-renders linear in the visible-row count.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
var loader = window.app.modules.loader;
|
|
|
|
// ── Model helpers ────────────────────────────────────────────────────
|
|
|
|
function newNode(raw, parentId, depth) {
|
|
var id = state.nextId++;
|
|
// ZIP files are treated as folders for tree purposes — the
|
|
// chevron lets the user expand them inline. The actual
|
|
// contents are loaded on first expand via JSZip.
|
|
var isZip = !raw.isDir && raw.ext === 'zip';
|
|
var node = {
|
|
id: id,
|
|
name: raw.name,
|
|
// displayName is the rendered label when set by the parent
|
|
// .zddc display: map. Sort + lookup continues to use .name
|
|
// (the on-disk basename) so URL composition stays canonical.
|
|
displayName: raw.displayName || '',
|
|
isDir: raw.isDir,
|
|
size: raw.size,
|
|
modTime: raw.modTime,
|
|
ext: raw.ext,
|
|
url: raw.url,
|
|
handle: raw.handle,
|
|
depth: depth,
|
|
parentId: parentId,
|
|
expanded: false,
|
|
loaded: false,
|
|
childIds: [],
|
|
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)
|
|
// 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;
|
|
}
|
|
|
|
function clearTree() {
|
|
state.nodes.clear();
|
|
state.rootIds = [];
|
|
state.nextId = 1;
|
|
}
|
|
|
|
// Sort an array of nodes by current sort key. Folders always come
|
|
// first within a level (mimics common file managers).
|
|
function sortNodes(ids) {
|
|
var key = state.sort.key;
|
|
var dir = state.sort.dir;
|
|
ids.sort(function (a, b) {
|
|
var na = state.nodes.get(a);
|
|
var nb = state.nodes.get(b);
|
|
// Folders before files
|
|
if (na.isDir !== nb.isDir) return na.isDir ? -1 : 1;
|
|
var av, bv;
|
|
switch (key) {
|
|
case 'size':
|
|
av = na.size; bv = nb.size; break;
|
|
case 'ext':
|
|
av = na.ext; bv = nb.ext; break;
|
|
case 'date':
|
|
av = na.modTime ? na.modTime.getTime() : 0;
|
|
bv = nb.modTime ? nb.modTime.getTime() : 0;
|
|
break;
|
|
default:
|
|
av = na.name.toLowerCase();
|
|
bv = nb.name.toLowerCase();
|
|
}
|
|
if (av < bv) return -1 * dir;
|
|
if (av > bv) return 1 * dir;
|
|
return na.name.toLowerCase().localeCompare(nb.name.toLowerCase());
|
|
});
|
|
}
|
|
|
|
// Populate state with the root listing.
|
|
function setRoot(rawEntries) {
|
|
clearTree();
|
|
rawEntries.forEach(function (raw) {
|
|
var n = newNode(raw, null, 0);
|
|
state.rootIds.push(n.id);
|
|
});
|
|
sortNodes(state.rootIds);
|
|
}
|
|
|
|
// Populate a folder's children. Caller passes raw entries in any order.
|
|
function setChildren(parentId, rawEntries) {
|
|
var parent = state.nodes.get(parentId);
|
|
if (!parent) return;
|
|
// Drop any existing children first (re-load case).
|
|
parent.childIds.forEach(function (id) { state.nodes.delete(id); });
|
|
parent.childIds = [];
|
|
rawEntries.forEach(function (raw) {
|
|
var n = newNode(raw, parentId, parent.depth + 1);
|
|
parent.childIds.push(n.id);
|
|
});
|
|
sortNodes(parent.childIds);
|
|
parent.loaded = true;
|
|
}
|
|
|
|
// Walk nodes in render order. Skips the children of a collapsed
|
|
// expandable.
|
|
function visibleIds() {
|
|
var out = [];
|
|
function walk(ids) {
|
|
for (var i = 0; i < ids.length; i++) {
|
|
var n = state.nodes.get(ids[i]);
|
|
if (!n) continue;
|
|
out.push(ids[i]);
|
|
if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds);
|
|
}
|
|
}
|
|
// Re-sort everything at all levels so a sort change reorders
|
|
// already-loaded children consistently.
|
|
sortNodes(state.rootIds);
|
|
state.nodes.forEach(function (n) {
|
|
if ((n.isDir || n.isZip) && n.loaded) sortNodes(n.childIds);
|
|
});
|
|
walk(state.rootIds);
|
|
return out;
|
|
}
|
|
|
|
// ── Rendering ────────────────────────────────────────────────────────
|
|
|
|
function fmtSize(bytes) {
|
|
if (bytes == null) return '';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
}
|
|
|
|
function fmtDate(d) {
|
|
if (!d) return '';
|
|
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
|
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
// Render a single tree row as a flat <div>. Indentation via
|
|
// padding-left so the row's hover background spans the full
|
|
// pane width. Files are rendered as plain rows (no anchor) —
|
|
// the preview pane handles click navigation, and a Ctrl/Cmd-
|
|
// click can fall back to opening the file's url in a new tab
|
|
// via the events.js click handler (it sees the modifier key).
|
|
function rowHtml(node) {
|
|
var indent = 0.4 + node.depth * 1.0;
|
|
var expandable = node.isDir || node.isZip;
|
|
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
|
|
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 + 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.displayName || node.name) + '</span>'
|
|
+ virtualHint
|
|
+ '</div>';
|
|
}
|
|
|
|
function render() {
|
|
var body = document.getElementById('treeBody');
|
|
if (!body) return;
|
|
var ids = visibleIds();
|
|
var html = '';
|
|
for (var i = 0; i < ids.length; i++) {
|
|
html += rowHtml(state.nodes.get(ids[i]));
|
|
}
|
|
body.innerHTML = html;
|
|
updateCount();
|
|
renderBreadcrumbs();
|
|
}
|
|
|
|
// Count nodes that render at the root + every expanded subtree.
|
|
function expandedSetSize() {
|
|
var n = 0;
|
|
function walk(ids) {
|
|
for (var i = 0; i < ids.length; i++) {
|
|
n++;
|
|
var node = state.nodes.get(ids[i]);
|
|
if (node && (node.isDir || node.isZip) && node.expanded) {
|
|
walk(node.childIds);
|
|
}
|
|
}
|
|
}
|
|
walk(state.rootIds);
|
|
return n;
|
|
}
|
|
|
|
function updateCount() {
|
|
var el = document.getElementById('entryCount');
|
|
if (!el) return;
|
|
var total = expandedSetSize();
|
|
el.textContent = total + ' item' + (total === 1 ? '' : 's');
|
|
}
|
|
|
|
// ── Breadcrumbs ──────────────────────────────────────────────────────
|
|
|
|
// Inline outline home icon. Stroke-based so it tints with the
|
|
// current text color rather than depending on emoji rendering.
|
|
var HOME_SVG = '<svg class="bc-home-icon" xmlns="http://www.w3.org/2000/svg" '
|
|
+ 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
|
|
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
|
|
+ '<path d="M3 12l9-9 9 9"/>'
|
|
+ '<path d="M5 10v10h14V10"/>'
|
|
+ '<path d="M10 20v-6h4v6"/></svg>';
|
|
|
|
function renderBreadcrumbs() {
|
|
var el = document.getElementById('breadcrumbs');
|
|
if (!el) return;
|
|
var html = '';
|
|
if (state.source === 'server') {
|
|
// Server mode: every segment links to its directory URL.
|
|
// The browser navigates → server returns embedded browse →
|
|
// the new instance auto-loads that directory's listing.
|
|
var path = state.currentPath || '/';
|
|
var parts = path.split('/').filter(Boolean);
|
|
html += '<a class="bc-link bc-root" href="/" title="Site root">'
|
|
+ HOME_SVG + '</a>';
|
|
var sofar = '';
|
|
for (var i = 0; i < parts.length; i++) {
|
|
sofar += '/' + parts[i];
|
|
var isLast = i === parts.length - 1;
|
|
html += '<span class="bc-sep">/</span>';
|
|
if (isLast) {
|
|
html += '<span class="bc-link bc-link--current">'
|
|
+ escapeHtml(parts[i]) + '</span>';
|
|
} else {
|
|
html += '<a class="bc-link" href="' + escapeHtml(sofar + '/') + '">'
|
|
+ escapeHtml(parts[i]) + '</a>';
|
|
}
|
|
}
|
|
html += '<span class="bc-sep">/</span>';
|
|
} else if (state.source === 'fs') {
|
|
// FS-API mode: ancestor handles weren't retained when the
|
|
// user picked the root, so we can't navigate up. Show the
|
|
// root icon + handle name without links.
|
|
var name = state.rootHandle ? state.rootHandle.name : '';
|
|
html += '<span class="bc-link bc-root" title="Local directory">'
|
|
+ HOME_SVG + '</span>';
|
|
if (name) {
|
|
html += '<span class="bc-sep">/</span>';
|
|
html += '<span class="bc-link bc-link--current">' + escapeHtml(name) + '</span>';
|
|
}
|
|
html += '<span class="bc-sep">/</span>';
|
|
}
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
// Sort headers no longer exist in the DOM (the tree replaced the
|
|
// table); the tree.setSort() method still works but only via
|
|
// programmatic callers — there's no UI for changing sort yet.
|
|
|
|
// Load a folder's children (lazy; idempotent re-loads). Dispatches
|
|
// by node kind:
|
|
// - regular folder → server JSON listing OR FS-API enumeration
|
|
// - zip file → fetch+JSZip; entries become virtual children
|
|
// - zip child dir → already-listed entries from the parent zip
|
|
// (zips are enumerated whole, so child dirs
|
|
// are pre-populated when the zip expands)
|
|
async function loadChildren(node) {
|
|
if (node.loaded) return;
|
|
try {
|
|
if (node.isZip) {
|
|
await loadZipChildren(node);
|
|
} else if (node._zipSyntheticDir) {
|
|
// Synthetic dir node materialized when a zip's entry
|
|
// list referenced "a/b/file" but had no "a/" entry.
|
|
// Re-walk the owning zip's flat entry list with the
|
|
// dir's full prefix.
|
|
var owner = state.nodes.get(node.zipParentId);
|
|
if (!owner || !owner.zipEntries) {
|
|
throw new Error('zip parent not loaded');
|
|
}
|
|
setZipDirChildren(node, owner, node.zipPath + '/');
|
|
} else if (node.isDir) {
|
|
var raw;
|
|
if (state.source === 'server') {
|
|
raw = await loader.fetchServerChildren(pathFor(node) + '/');
|
|
} else if (state.source === 'fs') {
|
|
raw = await loader.fetchFsChildren(node.handle);
|
|
} else {
|
|
return;
|
|
}
|
|
setChildren(node.id, raw);
|
|
}
|
|
} catch (e) {
|
|
window.app.modules.events.statusError(
|
|
'Failed to load ' + node.name + ': ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Fetch a zip's bytes, parse with JSZip, and materialize its
|
|
// entries as a tree of virtual nodes. JSZip's entry list is flat
|
|
// (full paths); we reconstruct the directory hierarchy on top.
|
|
async function loadZipChildren(zipNode) {
|
|
await loader.ensureJSZip();
|
|
var arrayBuffer;
|
|
if (state.source === 'server' && zipNode.url) {
|
|
var resp = await fetch(zipNode.url);
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + zipNode.url);
|
|
arrayBuffer = await resp.arrayBuffer();
|
|
} else if (zipNode.handle) {
|
|
// FS-API: top-level zip in a local folder.
|
|
var f = await zipNode.handle.getFile();
|
|
arrayBuffer = await f.arrayBuffer();
|
|
} else if (zipNode.zipParentId != null) {
|
|
// Nested zip inside another zip — read from parent JSZip.
|
|
var parent = state.nodes.get(zipNode.zipParentId);
|
|
if (!parent || !parent.zipFile) {
|
|
throw new Error('parent zip not loaded');
|
|
}
|
|
arrayBuffer = await parent.zipFile.file(zipNode.zipPath).async('arraybuffer');
|
|
} else {
|
|
throw new Error('cannot fetch zip bytes (no source)');
|
|
}
|
|
var zip = await window.JSZip.loadAsync(arrayBuffer);
|
|
zipNode.zipFile = zip;
|
|
|
|
// Build a path → raw-entry map. Entry paths are
|
|
// "dir/sub/file.ext" or "dir/" for directories. We slice
|
|
// to immediate children of zipNode (i.e. zero slashes after
|
|
// a leading prefix). For nested directories, we synthesize
|
|
// folder nodes that lazy-expand to the next level via the
|
|
// same raw-entry list — keep it on the zipNode for replay.
|
|
zipNode.zipEntries = []; // for re-walk on expand of subdirs
|
|
zip.forEach(function (relPath, entry) {
|
|
zipNode.zipEntries.push({
|
|
path: relPath.replace(/\/$/, ''),
|
|
isDir: entry.dir,
|
|
size: (entry._data && entry._data.uncompressedSize) || 0,
|
|
modTime: entry.date instanceof Date ? entry.date : null,
|
|
rawPath: relPath
|
|
});
|
|
});
|
|
|
|
// Now seed top-level children of the zip itself.
|
|
setZipDirChildren(zipNode, zipNode, '');
|
|
}
|
|
|
|
// Populate node's childIds with the entries directly under
|
|
// pathPrefix (relative to the owning zip). Directory entries
|
|
// become folder nodes whose own children are seeded on first
|
|
// expand by this same function (recursively descending zipPath).
|
|
function setZipDirChildren(node, zipOwner, pathPrefix) {
|
|
var seen = new Map(); // immediate child name → raw entry
|
|
zipOwner.zipEntries.forEach(function (e) {
|
|
if (!e.path.startsWith(pathPrefix)) return;
|
|
var rest = e.path.substring(pathPrefix.length);
|
|
if (rest === '') return;
|
|
// Take the FIRST segment of the remaining path
|
|
var slash = rest.indexOf('/');
|
|
var firstSeg = slash === -1 ? rest : rest.substring(0, slash);
|
|
var isImmediateFile = !e.isDir && slash === -1;
|
|
var isImmediateDir = e.isDir && slash === -1;
|
|
// For deeply-nested entries (rest contains a slash), we
|
|
// surface only the first segment as a synthetic folder
|
|
// entry. For immediate entries, we emit the entry as-is.
|
|
if (isImmediateFile || isImmediateDir) {
|
|
// Immediate entry — use the real metadata.
|
|
seen.set(firstSeg, {
|
|
name: firstSeg,
|
|
isDir: e.isDir,
|
|
size: e.size,
|
|
modTime: e.modTime,
|
|
ext: e.isDir ? '' : loader.splitExt(firstSeg),
|
|
url: null,
|
|
handle: null,
|
|
zipPath: e.path,
|
|
zipParentId: zipOwner.id
|
|
});
|
|
} else if (slash !== -1 && !seen.has(firstSeg)) {
|
|
// Deeper entry, no explicit dir entry yet — synthesize.
|
|
seen.set(firstSeg, {
|
|
name: firstSeg,
|
|
isDir: true,
|
|
size: 0,
|
|
modTime: null,
|
|
ext: '',
|
|
url: null,
|
|
handle: null,
|
|
zipPath: pathPrefix + firstSeg,
|
|
zipParentId: zipOwner.id
|
|
});
|
|
}
|
|
});
|
|
// Drop existing children (re-load case)
|
|
node.childIds.forEach(function (id) { state.nodes.delete(id); });
|
|
node.childIds = [];
|
|
seen.forEach(function (raw) {
|
|
var n = newNode(raw, node.id, node.depth + 1);
|
|
// Synthetic dir nodes inside zip don't have a dedicated
|
|
// load path — they re-walk zipEntries on expand. Mark
|
|
// them so the dispatcher knows.
|
|
if (raw.isDir && !n.isZip) {
|
|
n._zipSyntheticDir = true;
|
|
}
|
|
node.childIds.push(n.id);
|
|
});
|
|
sortNodes(node.childIds);
|
|
node.loaded = true;
|
|
}
|
|
|
|
// Toggle a folder's expanded state. Loads children on first expand.
|
|
// Treats "expandable" as either a real directory OR a zip file
|
|
// (zip files act like folders for tree purposes — the chevron
|
|
// expands them and the contents come from JSZip).
|
|
async function toggleFolder(nodeId) {
|
|
var n = state.nodes.get(nodeId);
|
|
if (!n || !(n.isDir || n.isZip)) return;
|
|
if (!n.expanded && !n.loaded) {
|
|
await loadChildren(n);
|
|
if (!n.loaded) return; // load failed (statusError already set)
|
|
}
|
|
n.expanded = !n.expanded;
|
|
render();
|
|
}
|
|
|
|
// Recursive expand: load + expand all descendants of nodeId. Used
|
|
// for Shift-click on a folder. Walks breadth-first, fanning out
|
|
// through children, grand-children, etc. until every reachable
|
|
// expandable node (folder OR zip) is loaded and marked expanded.
|
|
// Skips zip-EXPANSION recursion to avoid auto-loading every
|
|
// archive in the tree (those can be huge); plain folders only.
|
|
async function expandSubtree(nodeId) {
|
|
var root = state.nodes.get(nodeId);
|
|
if (!root || !(root.isDir || root.isZip)) return;
|
|
var status = window.app.modules.events.statusInfo;
|
|
status('Expanding subtree…');
|
|
var processed = 0;
|
|
var queue = [root];
|
|
while (queue.length) {
|
|
var batch = queue;
|
|
queue = [];
|
|
await Promise.all(batch.map(function (n) { return loadChildren(n); }));
|
|
for (var i = 0; i < batch.length; i++) {
|
|
var n = batch[i];
|
|
n.expanded = true;
|
|
processed++;
|
|
for (var j = 0; j < n.childIds.length; j++) {
|
|
var c = state.nodes.get(n.childIds[j]);
|
|
// Recurse into plain folders only — don't auto-
|
|
// expand zip archives during a subtree expand
|
|
// (they can be very large).
|
|
if (c && c.isDir && !c.isZip) queue.push(c);
|
|
}
|
|
}
|
|
render();
|
|
status('Expanding subtree… (' + processed + ' folders loaded)');
|
|
}
|
|
status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
|
|
}
|
|
|
|
function collapseSubtree(nodeId) {
|
|
var root = state.nodes.get(nodeId);
|
|
if (!root || !(root.isDir || root.isZip)) return;
|
|
function walk(n) {
|
|
n.expanded = false;
|
|
for (var i = 0; i < n.childIds.length; i++) {
|
|
var c = state.nodes.get(n.childIds[i]);
|
|
if (c && (c.isDir || c.isZip)) walk(c);
|
|
}
|
|
}
|
|
walk(root);
|
|
render();
|
|
}
|
|
|
|
// Compute the URL/path for a node by walking parents.
|
|
function pathFor(node) {
|
|
var parts = [];
|
|
var cur = node;
|
|
while (cur) {
|
|
parts.unshift(cur.name);
|
|
cur = cur.parentId == null ? null : state.nodes.get(cur.parentId);
|
|
}
|
|
if (state.source === 'server') {
|
|
// currentPath is the dir containing rootIds — root nodes
|
|
// sit DIRECTLY under it.
|
|
return state.currentPath.replace(/\/$/, '') + '/' + parts.join('/');
|
|
}
|
|
return parts.join('/');
|
|
}
|
|
|
|
// Public API
|
|
window.app.modules.tree = {
|
|
setRoot: setRoot,
|
|
setChildren: setChildren,
|
|
render: render,
|
|
toggleFolder: toggleFolder,
|
|
expandSubtree: expandSubtree,
|
|
collapseSubtree: collapseSubtree,
|
|
setSort: function (key) {
|
|
if (state.sort.key === key) {
|
|
state.sort.dir = -state.sort.dir;
|
|
} else {
|
|
state.sort.key = key;
|
|
state.sort.dir = 1;
|
|
}
|
|
render();
|
|
},
|
|
// Set both key and direction explicitly. dir: 1 (asc) or -1 (desc).
|
|
// Used by the toolbar's sort dropdown.
|
|
setSortExplicit: function (key, dir) {
|
|
state.sort.key = key;
|
|
state.sort.dir = (dir === -1 ? -1 : 1);
|
|
render();
|
|
},
|
|
pathFor: pathFor
|
|
};
|
|
})();
|