Modifications to make lazy trees possible

This commit is contained in:
2025-12-28 01:21:53 +01:00
parent 4e04f1eae9
commit 0123378aca

View File

@@ -5,6 +5,7 @@ This program is available under Apache License Version 2.0
*/ */
import '@tp/tp-icon/tp-icon.js'; import '@tp/tp-icon/tp-icon.js';
import '@tp/tp-spinner/tp-spinner.js';
import '@tp/tp-popup/tp-popup-menu.js'; import '@tp/tp-popup/tp-popup-menu.js';
import { LitElement, html, css, svg } from 'lit'; import { LitElement, html, css, svg } from 'lit';
import { virtualize } from '@lit-labs/virtualizer/virtualize.js'; import { virtualize } from '@lit-labs/virtualizer/virtualize.js';
@@ -167,9 +168,12 @@ export class TpTreeNav extends Position(LitElement) {
const { node, depth, hasChildren, expanded } = item; const { node, depth, hasChildren, expanded } = item;
const icon = this._resolveIcon(node?.icon); const icon = this._resolveIcon(node?.icon);
const states = Array.isArray(node?.states) ? node.states : [];
const isLoading = states.includes('loading');
const custom = this.renderNode?.(item, { const custom = this.renderNode?.(item, {
depth, depth,
states: Array.isArray(node?.states) ? node.states : [], states,
path: item.path, path: item.path,
hasChildren, hasChildren,
expanded, expanded,
@@ -192,7 +196,9 @@ export class TpTreeNav extends Position(LitElement) {
> >
<div class="indent"></div> <div class="indent"></div>
<div class="chevron-btn ${expanded ? 'expanded' : ''}" part="chevron" ?hidden=${!hasChildren} @click=${(e) => this._onChevronClick(item, e)} > <div class="chevron-btn ${expanded ? 'expanded' : ''}" part="chevron" ?hidden=${!hasChildren} @click=${(e) => this._onChevronClick(item, e)} >
<tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon> ${isLoading
? html`<tp-spinner class="icon" part="spinner"></tp-spinner>`
: html`<tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon>`}
</div> </div>
${icon ? html`<tp-icon class="icon" part="icon" .icon=${icon}></tp-icon>` : html`<span class="icon" part="icon" aria-hidden="true"></span>`} ${icon ? html`<tp-icon class="icon" part="icon" .icon=${icon}></tp-icon>` : html`<span class="icon" part="icon" aria-hidden="true"></span>`}
<div class="label" part="label">${node?.label ?? ''}</div> <div class="label" part="label">${node?.label ?? ''}</div>
@@ -261,6 +267,7 @@ export class TpTreeNav extends Position(LitElement) {
selectOnRightClick: { type: Boolean }, selectOnRightClick: { type: Boolean },
allowDragAndDrop: { type: Boolean }, allowDragAndDrop: { type: Boolean },
canDrop: { type: Function }, canDrop: { type: Function },
loadChildren: { type: Function },
}; };
} }
@@ -306,6 +313,7 @@ export class TpTreeNav extends Position(LitElement) {
this.selectOnRightClick = false; this.selectOnRightClick = false;
this.allowDragAndDrop = false; this.allowDragAndDrop = false;
this.canDrop = null; this.canDrop = null;
this.loadChildren = null;
this._contextMenu = null; this._contextMenu = null;
this._outsideHandler = null; this._outsideHandler = null;
this._keyHandler = null; this._keyHandler = null;
@@ -313,6 +321,8 @@ export class TpTreeNav extends Position(LitElement) {
this._knownPaths = new Set(); this._knownPaths = new Set();
this._expandedPathSet = new Set(); this._expandedPathSet = new Set();
this._selectedPathSet = new Set(); this._selectedPathSet = new Set();
this._childLoadControllers = new Map();
this._childLoadSeq = 0;
} }
disconnectedCallback() { disconnectedCallback() {
@@ -342,7 +352,7 @@ export class TpTreeNav extends Position(LitElement) {
const slug = node?.slug ?? `${index}`; const slug = node?.slug ?? `${index}`;
const nextPath = [...path, slug]; const nextPath = [...path, slug];
const states = Array.isArray(node?.states) ? node.states : []; const states = Array.isArray(node?.states) ? node.states : [];
const hasChildren = Array.isArray(node?.children) && node.children.length > 0; const hasChildren = this._isExpandableNode(node);
const expanded = states.includes('expanded'); const expanded = states.includes('expanded');
results.push({ results.push({
@@ -354,7 +364,7 @@ export class TpTreeNav extends Position(LitElement) {
key: nextPath.join('/'), key: nextPath.join('/'),
}); });
if (hasChildren && expanded) { if (hasChildren && expanded && Array.isArray(node?.children) && node.children.length > 0) {
walk(node.children, depth + 1, nextPath); walk(node.children, depth + 1, nextPath);
} }
}); });
@@ -364,6 +374,17 @@ export class TpTreeNav extends Position(LitElement) {
return results; return results;
} }
_isExpandableNode(node) {
if (!node) return false;
if (Array.isArray(node.children) && node.children.length > 0) return true;
if (typeof this.loadChildren === 'function') {
// If consumer provides an `isDir` hint, don't show chevrons for non-dirs.
if (typeof node.isDir === 'boolean') return node.isDir;
return true;
}
return false;
}
_onRowClick(item, originalEvent) { _onRowClick(item, originalEvent) {
const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2; const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2;
@@ -415,21 +436,177 @@ export class TpTreeNav extends Position(LitElement) {
} }
_toggleExpand(item, source, originalEvent) { _toggleExpand(item, source, originalEvent) {
const shouldLoad = typeof this.loadChildren === 'function';
if (this.manageState) { if (this.manageState) {
const pathStr = item.path.join('/'); const pathStr = item.path.join('/');
const set = new Set(this._expandedPathSet); const set = new Set(this._expandedPathSet);
if (set.has(pathStr)) { const wasExpanded = set.has(pathStr);
if (wasExpanded) {
set.delete(pathStr); set.delete(pathStr);
} else { this._expandedPathSet = set;
set.add(pathStr); this.expandedPaths = Array.from(set);
this._rebuildManagedTree();
this._dispatchAction('toggle', item, source, originalEvent);
return;
} }
// If we're about to load children, set loading state first so the immediate rebuild
// renders a spinner (and the row stays expandable) rather than flashing an "empty" state.
if (shouldLoad) {
this._updateNodeStatesByKey(item.key, (n) => {
const states = Array.isArray(n.states) ? n.states : [];
const next = states.filter((s) => s !== 'loaded');
if (!next.includes('loading')) next.push('loading');
return { ...n, states: next };
});
}
set.add(pathStr);
this._expandedPathSet = set; this._expandedPathSet = set;
this.expandedPaths = Array.from(set); this.expandedPaths = Array.from(set);
this._rebuildManagedTree(); this._rebuildManagedTree();
this._dispatchAction('toggle', item, source, originalEvent);
if (shouldLoad) {
// Load immediately; don't rely on a microtask where external code may reset `roots`.
// Prefer the node from the item we just interacted with.
this._loadChildrenForExpanded(item.key, source, originalEvent, item.node, item.path);
}
return;
} }
this._dispatchAction('toggle', item, source, originalEvent); this._dispatchAction('toggle', item, source, originalEvent);
} }
_findNodeByKey(key) {
let found = null;
const walk = (nodes, path) => {
if (!Array.isArray(nodes)) return;
nodes.forEach((node, index) => {
if (found) return;
const slug = node?.slug ?? `${index}`;
const nextPath = [...path, slug];
const nodeKey = nextPath.join('/');
if (nodeKey === key) {
found = node;
return;
}
if (Array.isArray(node?.children) && node.children.length > 0) {
walk(node.children, nextPath);
}
});
};
walk(this.roots, []);
return found;
}
getRootKey() {
const roots = Array.isArray(this.roots) ? this.roots : [];
if (!roots.length) return '';
const first = roots[0];
const slug = first?.slug ?? '0';
return String(slug);
}
_updateNodeStatesByKey(key, updater) {
const map = (nodes, path = []) => {
if (!Array.isArray(nodes)) return nodes;
return nodes.map((node, index) => {
const slug = node?.slug ?? `${index}`;
const nextPath = [...path, slug];
const nodeKey = nextPath.join('/');
let nextNode = node;
if (nodeKey === key) {
nextNode = updater(node) ?? node;
}
const children = Array.isArray(nextNode?.children)
? map(nextNode.children, nextPath)
: nextNode?.children;
if (children !== nextNode?.children) {
return { ...nextNode, children };
}
return nextNode;
});
};
this.roots = map(this.roots);
}
async _loadChildrenForExpanded(key, source, originalEvent, nodeHint = null, pathHint = null) {
if (typeof this.loadChildren !== 'function') return;
// Prefer the node passed from the click handler; it is the most stable reference.
// Fall back to searching the current tree by key.
const node = nodeHint ?? this._findNodeByKey(key);
if (!node) {
return;
}
const controller = new AbortController();
const seq = ++this._childLoadSeq;
const previous = this._childLoadControllers.get(key);
if (previous) {
try { previous.abort(); } catch (e) { }
}
this._childLoadControllers.set(key, controller);
this._updateNodeStatesByKey(key, (n) => {
const states = Array.isArray(n.states) ? n.states : [];
const next = states.filter((s) => s !== 'loaded');
if (!next.includes('loading')) next.push('loading');
return { ...n, states: next };
});
try {
const ctx = {
node,
key,
path: Array.isArray(pathHint) ? pathHint : key.split('/').filter(Boolean),
signal: controller.signal,
source,
originalEvent,
};
// loadChildren is responsible for updating the tree (e.g. setting `node.children`).
await Promise.resolve(this.loadChildren(ctx));
if (controller.signal.aborted) return;
if (this._childLoadControllers.get(key) !== controller) return;
if (seq !== this._childLoadSeq) {
// newer load started elsewhere; ignore
return;
}
this._updateNodeStatesByKey(key, (n) => {
const states = Array.isArray(n.states) ? n.states : [];
const next = states.filter((s) => s !== 'loading');
if (!next.includes('loaded')) next.push('loaded');
return { ...n, states: next };
});
} catch (err) {
if (controller.signal.aborted) return;
this._updateNodeStatesByKey(key, (n) => {
const states = Array.isArray(n.states) ? n.states : [];
const next = states.filter((s) => s !== 'loading');
return { ...n, states: next };
});
this.dispatchEvent(new CustomEvent('node-children-error', {
detail: { key, node, error: err, source, originalEvent },
bubbles: true,
composed: true,
}));
} finally {
if (this._childLoadControllers.get(key) === controller) {
this._childLoadControllers.delete(key);
}
}
}
async _onContextMenu(item, originalEvent) { async _onContextMenu(item, originalEvent) {
originalEvent.preventDefault(); originalEvent.preventDefault();
@@ -915,12 +1092,12 @@ export class TpTreeNav extends Position(LitElement) {
const mappedNode = mapper(node, nextPath) ?? node; const mappedNode = mapper(node, nextPath) ?? node;
const { nodes: childNodes, changed: childChanged } = this._mapTree( const { nodes: childNodes, changed: childChanged } = this._mapTree(
node?.children, mappedNode?.children,
mapper, mapper,
nextPath nextPath
); );
const children = childChanged ? childNodes : node?.children; const children = childChanged ? childNodes : mappedNode?.children;
const nodeChanged = mappedNode !== node || childChanged; const nodeChanged = mappedNode !== node || childChanged;
if (nodeChanged) { if (nodeChanged) {
@@ -969,7 +1146,7 @@ export class TpTreeNav extends Position(LitElement) {
const children = Array.isArray(node?.children) const children = Array.isArray(node?.children)
? node.children.map((child) => mapNode(child, fullPath)) ? node.children.map((child) => mapNode(child, fullPath))
: []; : node?.children;
return { return {
...node, ...node,