// // Generic TreeDataProvider over the node model. Subclasses only implement // roots(); everything else — lazy children, TreeItem mapping — happens here. // const log = require("../util/log.js"); const { message } = require("./nodes.js"); // The parts of a node that end up on screen; a change to any of them means the // row has to be redrawn. const RENDERED_FIELDS = [ "name", "descriptiveText", "tooltip", "image", "path", "command", "contextValue", ]; /** Drops nodes that are already covered by an ancestor in the same list. */ function topmost(nodes) { const unique = [...new Set(nodes)]; return unique.filter((node) => { for (let parent = node.parent; parent; parent = parent.parent) { if (unique.includes(parent)) return false; } return true; }); } class NodeProvider { constructor(store) { this.store = store; this._roots = null; // Expansion has to be tracked here. A reload rebuilds every node, and // the collapsible state we hand back is what Nova applies, so without // this every refresh would close whatever the user had opened. this._expanded = new Set(); this._known = new Set(); } /** Records what the user opened or closed, keyed by node identifier. */ setExpanded(element, expanded) { if (!element || !element.identifier) return; if (expanded) this._expanded.add(element.identifier); else this._expanded.delete(element.identifier); } /** * Whether a row should render open. A node marked `expanded` seeds the set * the first time it is seen, so a default-open section can still be closed * by hand and stay closed. */ isExpanded(element) { const id = element.identifier; if (!id) return element.expanded === true; if (!this._known.has(id)) { this._known.add(id); if (element.expanded) this._expanded.add(id); } return this._expanded.has(id); } /** Subclasses return an array of nodes (or a Promise of one). */ roots() { return []; } /** Drops memoised roots so the next getChildren rebuilds from the store. */ invalidate() { this._roots = null; } /** * Rebuilds from the store and merges the result into the node objects Nova * is already holding, rather than replacing them. * * This is what lets the tree update as often as it likes without the view * jumping: a full `TreeView.reload()` rebuilds every row and drops the * scroll position, so instead this reports the smallest set of rows that * actually changed and only those get reloaded. * * Returns `{ full: true }` when the shape changed too much to patch, * `{ full: false, nodes }` for a targeted reload, or null when nothing * changed at all. */ async update() { const built = await this.roots(); for (const root of built) root.parent = null; if (!this._roots) { this._roots = built; return { full: true }; } const changed = []; this._roots = this._merge(this._roots, built, changed, null); if (changed.includes(null)) return { full: true }; if (!changed.length) return null; return { full: false, nodes: topmost(changed) }; } /** * Reconciles one level. Rows are matched by identifier and updated in * place; if the row set itself moved, the owner is rebuilt instead. */ _merge(previous, next, changed, owner) { const sameRows = previous.length === next.length && previous.every((node, index) => node.identifier === next[index].identifier); if (!sameRows) { for (const node of next) node.parent = owner; changed.push(owner); return next; } return previous.map((node, index) => { const fresh = next[index]; if (this._apply(node, fresh)) changed.push(node); if (node.children && fresh.children) { node.children = this._merge(node.children, fresh.children, changed, node); } else if (node.children && fresh.load) { // An open row that fetches its own children: drop the cache so // the reload refetches, which is the running-job case. node.children = null; changed.push(node); } return node; }); } /** Copies the fresh row's content onto the live node; true if visibly different. */ _apply(node, fresh) { let dirty = false; for (const field of RENDERED_FIELDS) { if (node[field] !== fresh[field]) { node[field] = fresh[field]; dirty = true; } } // Carried across whether or not they are drawn: commands read them. node.data = fresh.data; node.repo = fresh.repo; node.load = fresh.load; node.expanded = fresh.expanded; return dirty; } async getChildren(element) { try { if (!element) { if (!this._roots) { this._roots = await this.roots(); // A section may hoist a repository's children to the top // level; those nodes must not keep a parent Nova cannot see. for (const root of this._roots) root.parent = null; } return this._roots; } if (element.children) return element.children; if (element.load) { const children = await element.load(); element.children = children || []; for (const child of element.children) child.parent = element; return element.children; } return []; } catch (error) { log.error("tree build failed", String(error && error.stack ? error.stack : error)); return [message(`⚠ ${error.message || error}`)]; } } /** * Finds a rebuilt node by identifier, walking only branches the user has * open. That is enough to locate anything currently on screen, and it * avoids triggering a fetch for collapsed rows. */ async findByIdentifier(identifier) { if (!identifier) return null; const walk = async (candidates) => { for (const candidate of candidates) { if (candidate.identifier === identifier) return candidate; if (!this._expanded.has(candidate.identifier)) continue; const found = await walk(await this.getChildren(candidate)); if (found) return found; } return null; }; return await walk(await this.getChildren(null)); } /** Overridden per section: what this section draws, as a comparable string. */ signature() { return null; } getParent(element) { return element ? element.parent : null; } getTreeItem(element) { const expandable = Boolean(element.children || element.load); const collapsible = !expandable ? TreeItemCollapsibleState.None : this.isExpanded(element) ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed; const item = new TreeItem(element.name, collapsible); item.identifier = element.identifier; item.contextValue = element.contextValue; if (element.descriptiveText) item.descriptiveText = element.descriptiveText; if (element.tooltip) item.tooltip = element.tooltip; if (element.image) item.image = element.image; if (element.path) item.path = element.path; if (element.command) item.command = element.command; return item; } } exports.NodeProvider = NodeProvider;