diff --git a/CHANGELOG.md b/CHANGELOG.md index 370682d..18596cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,5 +29,6 @@ Initial release. - Adaptive polling and a notification when a workflow run fails. - Gitea Actions CI running the test suites, script syntax checks, manifest validation, and a generated-image check. -- The sidebar keeps its open rows and scroll position across refreshes, and a - poll that changes nothing no longer redraws it at all. +- The sidebar keeps its open rows and its scroll position while it refreshes. + Only the rows that actually changed are reloaded, so watching a running job + updates that job's row and leaves the rest of the tree where it is. diff --git a/CLAUDE.md b/CLAUDE.md index 20aa5d7..7f63b10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,12 +70,8 @@ Controllers mutate through it. It owns: - `repos`, `unmatchedHosts`, `instanceStatus` - per-run `jobs`/`artifacts`, per-PR `pullRequestDetail`, `secrets`, `variables` -- `onDidChange` → `main.js` debounces 150ms, compares `store.signature()`, and - reloads only when it changed. An idle poll therefore leaves the tree, its - scroll position, and its open rows completely alone. Anything that changes - what is drawn has to appear in that signature — the branch filter is in there - for exactly that reason, and a live run contributes a coarse time bucket so - its elapsed clock keeps moving. +- `onDidChange` → `main.js` debounces 150ms, then asks each section whether it + needs anything (see **Redrawing** below) - adaptive polling: `runningInterval` when anything is queued or running, else `idleInterval`. `refresh()` coalesces — a second call while one is in flight returns the same promise rather than stacking. @@ -94,6 +90,36 @@ identifier is seen, so a default-open section can still be closed and stay closed. **Node identifiers must therefore be stable across rebuilds** — never derive one from a counter or from anything that changes between refreshes. +## Redrawing + +Nova has no API to read or set a tree's scroll offset, and `TreeView.reload()` +with no argument rebuilds every row and drops the scroll position. Polling every +15 seconds while a job runs therefore cannot go through a full reload. Three +things keep the view still, in order of how often they fire: + +1. **Per-section signatures.** Each provider implements `signature()` describing + only what it draws. `main.js` compares it and skips the section entirely when + unchanged, so run activity never disturbs the Pull Requests section and an + idle tree is never touched. Anything that changes the drawing must be in + there — the branch filter is, because it changes the tree without changing + any data. A section showing a run in progress adds a coarse time bucket + (`store.elapsedTick`) so its elapsed clock keeps moving. +2. **`provider.update()` reconciles in place.** It rebuilds from the store and + merges the result onto the node objects Nova already holds, matching rows by + identifier and copying the rendered fields across. Node identity survives, + which is what makes a targeted reload possible at all. +3. **Targeted `view.reload(node)`.** `update()` returns the topmost rows that + actually changed, and only those are reloaded. Watching a running job + reloads that one run's row, nothing else. + +`update()` returns `{ full: true }` when a row set changed at the root and the +merge cannot patch it; only then does a whole-tree reload happen, and +`fullReload` re-reveals the selected row afterwards as the nearest thing to a +scroll anchor Nova offers. + +An open row whose children are lazily loaded has its `children` cache dropped by +the merge, so the reload refetches — that is the running-job path. Keep that. + **`controllers/selection.js`** — a command can arrive from a sidebar (Nova passes the `TreeView`), from a node directly, or from a menu with no argument at all. `selectedNode()` normalises all three; `resolveRepo()` falls back to the diff --git a/Scripts/main.js b/Scripts/main.js index 7d02ae4..f4b36e3 100644 --- a/Scripts/main.js +++ b/Scripts/main.js @@ -59,11 +59,60 @@ function buildSections(store) { }); } -/** Rebuilds every section from current store state. */ -function reloadSections(sections) { - for (const section of sections) { - section.provider.invalidate(); - section.view.reload().catch((error) => log.debug("reload failed", String(error))); +/** + * Applies a section's changes to the view. Only the rows that actually changed + * are reloaded, which is what keeps the scroll position while a job is being + * watched. A shape change that cannot be patched falls back to a full reload, + * and there the selected row is used to put the view back roughly where it was + * — Nova exposes no way to read or set a tree's scroll offset directly. + */ +async function reloadSection(section) { + let update; + try { + update = await section.provider.update(); + } catch (error) { + log.debug("could not rebuild the section", String(error)); + return; + } + + if (!update) return; + + if (!update.full) { + for (const node of update.nodes) { + try { + await section.view.reload(node); + } catch (error) { + // Nova may not know this row; redraw everything instead. + log.debug("row reload failed, falling back to a full reload", String(error)); + await fullReload(section); + return; + } + } + return; + } + + await fullReload(section); +} + +async function fullReload(section) { + const anchor = (section.view.selection || [])[0]; + const identifier = anchor ? anchor.identifier : null; + + try { + await section.view.reload(); + } catch (error) { + log.debug("reload failed", String(error)); + return; + } + + if (!identifier) return; + + try { + const restored = await section.provider.findByIdentifier(identifier); + if (restored) await section.view.reveal(restored, { select: true, focus: false }); + } catch (error) { + // The row may simply be gone now; losing the anchor is not an error. + log.debug("could not restore the selection", String(error)); } } @@ -153,11 +202,12 @@ exports.activate = function () { ); let timer = null; - let lastSignature = null; + const signatures = new Map(); - /** Drops the memo so the next change redraws unconditionally. */ + /** Forces the next change to rebuild every section from scratch. */ const redraw = () => { - lastSignature = null; + signatures.clear(); + for (const section of sections) section.provider.invalidate(); }; const scheduleReload = () => { @@ -165,16 +215,15 @@ exports.activate = function () { timer = setTimeout(() => { timer = null; - // Reloading rebuilds every row, which is disruptive even with - // expansion preserved, so a poll that changed nothing does nothing. - const signature = store.signature(); - if (signature === lastSignature) { - log.debug("nothing changed; leaving the sidebar alone"); - return; - } - lastSignature = signature; + // Each section decides for itself. Run activity must not redraw the + // pull request list, and an idle poll must redraw nothing at all. + for (const section of sections) { + const signature = section.provider.signature(); + if (signature !== null && signature === signatures.get(section.id)) continue; - reloadSections(sections); + signatures.set(section.id, signature); + reloadSection(section); + } }, RELOAD_DEBOUNCE_MS); }; diff --git a/Scripts/store.js b/Scripts/store.js index d25d8d1..8059df3 100644 --- a/Scripts/store.js +++ b/Scripts/store.js @@ -328,73 +328,75 @@ class Store { this.notify(); } - /** - * Fingerprint of everything the sidebar draws. `main.js` compares it before - * reloading, so an idle poll that changes nothing leaves the tree — and the - * user's scroll position and open rows — completely alone. - */ - signature() { - const parts = []; + // -- Change detection -------------------------------------------------- + // + // Sections compose these into their own signature. Reloading rebuilds every + // row and loses the scroll position, so a section redraws only when what it + // actually shows has changed — run churn must not disturb someone reading + // pull requests, and nothing at all should disturb an idle tree. - // The loading flag only reaches the screen while there is nothing else - // to show; including it always would defeat the whole comparison. + /** Whether there is anything to draw, and which repositories. */ + baseSignature() { + const parts = [ + `error:${this.lastError || ""}`, + `unmatched:${this.unmatchedHosts.map((entry) => entry.host).join(",")}`, + `repos:${this.repos.map((repo) => `${repo.key}/${repo.branch || ""}`).join(",")}`, + ]; + // Only reaches the screen while there is nothing else to show. if (!this.repos.length) parts.push(`loading:${this.loading}`); - parts.push(`error:${this.lastError || ""}`); - - for (const entry of this.unmatchedHosts) parts.push(`unmatched:${entry.host}`); - - // The branch filter changes what is drawn without changing any data. - const filter = config.branchFilter(); - parts.push(`filter:${filter.mode}:${filter.branch || ""}`); - - for (const [baseUrl, status] of this.instanceStatus) { - parts.push( - `instance:${baseUrl}:${status.ok}:${status.version || ""}:${status.user || ""}:${status.error || ""}`, - ); - } - - for (const repo of this.repos) { - parts.push(`repo:${repo.key}:${repo.branch || ""}`); - - const runs = this.runsFor(repo.key); - parts.push(`runs:${runs.error || ""}:${runs.legacy}`); - for (const run of runs.runs) { - parts.push(`run:${run.id}:${run.status}:${run.conclusion}:${run.updated_at || ""}`); - } - - const pulls = this.pullRequestsFor(repo.key); - parts.push(`pulls:${pulls.error || ""}`); - for (const pull of pulls.items) { - parts.push(`pull:${pull.number}:${pull.state}:${pull.draft}:${pull.updated_at}`); - } - - const secrets = this.secrets.get(repo.key); - if (secrets) { - parts.push( - `secrets:${secrets.error || ""}:${secrets.items.map((x) => x.name).join(",")}`, - ); - } - - const variables = this.variables.get(repo.key); - if (variables) { - parts.push( - `variables:${variables.error || ""}:${variables.items - .map((x) => `${x.name}=${x.data || x.value || ""}`) - .join(",")}`, - ); - } - } - - // While something is running its elapsed time is on screen and has to - // keep moving, so let the signature change once per active interval. - if (this.hasActiveRuns) { - const seconds = Math.max(5, config.runningInterval()); - parts.push(`tick:${Math.floor(Date.now() / 1000 / seconds)}`); - } - return parts.join("|"); } + runsSignature(repoKey) { + const entry = this.runsFor(repoKey); + return [ + `runs:${entry.error || ""}/${entry.legacy}`, + ...entry.runs.map( + (run) => `${run.id}/${run.status}/${run.conclusion}/${run.updated_at || ""}`, + ), + ].join(","); + } + + pullsSignature(repoKey) { + const entry = this.pullRequestsFor(repoKey); + return [ + `pulls:${entry.error || ""}`, + ...entry.items.map( + (pull) => `${pull.number}/${pull.state}/${pull.draft}/${pull.updated_at}`, + ), + ].join(","); + } + + instancesSignature() { + return [...this.instanceStatus.entries()] + .map(([url, s]) => `${url}/${s.ok}/${s.version || ""}/${s.user || ""}/${s.error || ""}`) + .join(","); + } + + settingsSignature(repoKey) { + const secrets = this.secrets.get(repoKey); + const variables = this.variables.get(repoKey); + return [ + secrets ? `s:${secrets.error || ""}/${secrets.items.map((x) => x.name).join(",")}` : "s:-", + variables + ? `v:${variables.error || ""}/${variables.items + .map((x) => `${x.name}=${x.data || x.value || ""}`) + .join(",")}` + : "v:-", + ].join("|"); + } + + /** + * A coarse time bucket, present only while one of `runs` is in progress, so + * a section showing a live elapsed clock keeps it moving — and a section + * showing none of them stays perfectly still. + */ + elapsedTick(runs) { + if (!runs.some(isActiveRun)) return ""; + const seconds = Math.max(5, config.runningInterval()); + return `tick:${Math.floor(Date.now() / 1000 / seconds)}`; + } + // -- Housekeeping ------------------------------------------------------ _pruneCaches() { diff --git a/Scripts/views/branchView.js b/Scripts/views/branchView.js index c94a776..e49c941 100644 --- a/Scripts/views/branchView.js +++ b/Scripts/views/branchView.js @@ -11,6 +11,18 @@ const { NodeProvider } = require("./provider.js"); const { node, message, errorNode } = nodes; class BranchProvider extends NodeProvider { + signature() { + const store = this.store; + const filter = config.branchFilter(); + const parts = [store.baseSignature(), `filter:${filter.mode}:${filter.branch || ""}`]; + + for (const repo of store.repos) { + parts.push(store.runsSignature(repo.key), store.pullsSignature(repo.key)); + parts.push(store.elapsedTick(store.runsFor(repo.key).runs)); + } + return parts.join("|"); + } + roots() { const store = this.store; diff --git a/Scripts/views/provider.js b/Scripts/views/provider.js index d3f8b32..848883f 100644 --- a/Scripts/views/provider.js +++ b/Scripts/views/provider.js @@ -6,6 +6,29 @@ 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; @@ -51,6 +74,88 @@ class NodeProvider { 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) { @@ -79,6 +184,33 @@ class NodeProvider { } } + /** + * 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; } diff --git a/Scripts/views/pullRequestsView.js b/Scripts/views/pullRequestsView.js index b086875..6d0db47 100644 --- a/Scripts/views/pullRequestsView.js +++ b/Scripts/views/pullRequestsView.js @@ -10,6 +10,15 @@ const { NodeProvider } = require("./provider.js"); const { node, message, errorNode } = nodes; class PullRequestsProvider extends NodeProvider { + // No elapsed clock here, so this section never redraws on run activity. + signature() { + const store = this.store; + return [ + store.baseSignature(), + ...store.repos.map((repo) => store.pullsSignature(repo.key)), + ].join("|"); + } + roots() { const store = this.store; diff --git a/Scripts/views/settingsView.js b/Scripts/views/settingsView.js index ec00374..0f89a08 100644 --- a/Scripts/views/settingsView.js +++ b/Scripts/views/settingsView.js @@ -12,6 +12,16 @@ const { NodeProvider } = require("./provider.js"); const { node, message, errorNode } = nodes; class SettingsProvider extends NodeProvider { + signature() { + const store = this.store; + return [ + store.baseSignature(), + store.instancesSignature(), + `aliases:${[...store.router.aliases.keys()].join(",")}`, + ...store.repos.map((repo) => store.settingsSignature(repo.key)), + ].join("|"); + } + roots() { const store = this.store; const baseUrls = store.router.baseUrls; diff --git a/Scripts/views/workflowsView.js b/Scripts/views/workflowsView.js index cd6763c..e6ba488 100644 --- a/Scripts/views/workflowsView.js +++ b/Scripts/views/workflowsView.js @@ -10,6 +10,17 @@ const { NodeProvider } = require("./provider.js"); const { node, message, errorNode } = nodes; class WorkflowsProvider extends NodeProvider { + signature() { + const store = this.store; + const parts = [store.baseSignature()]; + + for (const repo of store.repos) { + parts.push(store.runsSignature(repo.key)); + parts.push(store.elapsedTick(store.runsFor(repo.key).runs)); + } + return parts.join("|"); + } + roots() { const store = this.store; diff --git a/Tests/integration.test.js b/Tests/integration.test.js index 5b3530f..587fb1d 100644 --- a/Tests/integration.test.js +++ b/Tests/integration.test.js @@ -312,23 +312,165 @@ function declaredCommands(manifest) { ); t.section("idle polls do not redraw"); - const first = store.signature(); - t.equals("an unchanged store yields an unchanged signature", store.signature(), first); + const providers = Object.fromEntries( + Object.entries(views).map(([id, view]) => [id, view.dataProvider]), + ); + const snapshot = () => + Object.fromEntries(Object.entries(providers).map(([id, p]) => [id, p.signature()])); - const trackedRuns = store.runsFor(store.repos[0].key).runs; - const finished = trackedRuns.find((run) => run.id === 103); - const previousConclusion = finished.conclusion; + const before = snapshot(); + t.check( + "an unchanged store leaves every section's signature alone", + Object.entries(snapshot()).every(([id, value]) => value === before[id]), + ); + + // Run 102 is deliberately still in progress in the fixture. + t.check( + "a live run keeps an elapsed clock in the signature", + providers["gitea.workflows"].signature().includes("tick:"), + ); + t.check( + "a section with no live run has none", + !providers["gitea.pullRequests"].signature().includes("tick:"), + ); + + const runs = store.runsFor(store.repos[0].key).runs; + const finished = runs.find((run) => run.id === 103); + const wasConcluded = finished.conclusion; finished.conclusion = "success"; - t.check("a changed run conclusion changes it", store.signature() !== first); - finished.conclusion = previousConclusion; + t.check("a run's conclusion changes the workflows section", + providers["gitea.workflows"].signature() !== before["gitea.workflows"]); + t.equals("but leaves the pull requests section alone", + providers["gitea.pullRequests"].signature(), before["gitea.pullRequests"]); + t.equals("and the settings section alone", + providers["gitea.settings"].signature(), before["gitea.settings"]); + finished.conclusion = wasConcluded; - t.equals("and restoring it restores the signature", store.signature(), first); + const pulls = store.pullRequestsFor(store.repos[0].key).items; + const wasUpdated = pulls[0].updated_at; + pulls[0].updated_at = new Date().toISOString(); + t.check("a pull request update changes the pull requests section", + providers["gitea.pullRequests"].signature() !== before["gitea.pullRequests"]); + t.equals("but leaves the workflows section alone", + providers["gitea.workflows"].signature(), before["gitea.workflows"]); + pulls[0].updated_at = wasUpdated; + + t.check( + "restoring the data restores every signature", + Object.entries(snapshot()).every(([id, value]) => value === before[id]), + ); - const branchFilterBefore = store.signature(); nova.workspace.config.set("gitea.branchFilter", "all"); - t.check("changing the branch filter changes it", store.signature() !== branchFilterBefore); + t.check("changing the branch filter redraws the branch section", + providers["gitea.branch"].signature() !== before["gitea.branch"]); + t.equals("without touching the workflows section", + providers["gitea.workflows"].signature(), before["gitea.workflows"]); nova.workspace.config.set("gitea.branchFilter", "current"); + // A run still in progress must keep its elapsed clock moving. + const live = runs.find((run) => run.id === 102); + t.equals("because the fixture leaves one running", live.status, "running"); + + t.section("only the rows that changed are reloaded"); + // Watching a job means the tree updates constantly. Rebuilding all of it + // would drop the scroll position, so only the affected rows are reloaded. + const wfView = views["gitea.workflows"]; + const wf = wfView.dataProvider; + + await wf.update(); + t.equals("a second update with no changes reports nothing", await wf.update(), null); + + const wfGroups = await wf.getChildren(null); + const ciGroupNode = wfGroups.find((n) => n.name === "ci.yml"); + wfView.expand(ciGroupNode); + const ciRuns = await wf.getChildren(ciGroupNode); + const liveRun = ciRuns.find((n) => n.data.run.id === 102); + const idleRun = ciRuns.find((n) => n.data.run.id === 101); + + // Open the running row, as someone monitoring a job would. + wfView.expand(liveRun); + await wf.getChildren(liveRun); + t.check("the open run has cached children", Array.isArray(liveRun.children)); + + // The job finishes. + const liveJobs = store.jobsFor(store.repos[0].key, 102); + liveJobs[0].status = "completed"; + liveJobs[0].conclusion = "success"; + runs.find((run) => run.id === 102).status = "completed"; + runs.find((run) => run.id === 102).conclusion = "success"; + + const targeted = await wf.update(); + t.check("an update is reported", Boolean(targeted)); + t.equals("without falling back to a full reload", targeted?.full, false); + t.check( + "and it names the run that changed", + targeted?.nodes.includes(liveRun), + targeted?.nodes.map((n) => n.identifier).join(", "), + ); + t.check( + "not the untouched sibling", + !targeted?.nodes.includes(idleRun), + targeted?.nodes.map((n) => n.identifier).join(", "), + ); + t.check("the open row's children are dropped so they refetch", liveRun.children === null); + + // Node identity has to survive, or Nova cannot match the reload to a row. + const groupsAfter = await wf.getChildren(null); + t.check("group objects are reused", groupsAfter.includes(ciGroupNode)); + t.check("run objects are reused", (await wf.getChildren(ciGroupNode)).includes(liveRun)); + t.check("the row's label was updated in place", liveRun.name.includes("✓"), liveRun.name); + + // A new run changes its workflow group's row set, so that group is + // reloaded — still not the whole tree. + const newRun = (id, workflow) => ({ + id, run_number: id, display_title: "New push", path: `.gitea/workflows/${workflow}`, + event: "push", status: "queued", conclusion: "", head_branch: "feature/login", + created_at: new Date().toISOString(), started_at: new Date().toISOString(), + }); + + runs.unshift(newRun(104, "ci.yml")); + const added = await wf.update(); + t.equals("a new run does not force a full reload", added?.full, false); + t.check( + "it reloads the workflow group it belongs to", + added?.nodes.includes(ciGroupNode), + added?.nodes.map((n) => n.identifier).join(", "), + ); + runs.shift(); + await wf.update(); + + // A whole new workflow adds a top-level row, which the row-by-row merge + // cannot patch. + runs.unshift(newRun(105, "release.yml")); + const reshaped = await wf.update(); + t.equals("a new workflow forces a full reload", reshaped?.full, true); + runs.shift(); + await wf.update(); + + t.section("the scroll anchor survives a reload"); + // Nova cannot report scroll position, so the selected row is the anchor. + const anchorView = views["gitea.workflows"]; + const anchorProvider = anchorView.dataProvider; + const anchorGroup = (await anchorProvider.getChildren(null)).find((n) => n.name === "ci.yml"); + anchorView.expand(anchorGroup); + const anchorRun = (await anchorProvider.getChildren(anchorGroup)).find( + (n) => n.data.run.id === 101, + ); + anchorView.selection = [anchorRun]; + + anchorProvider.invalidate(); + const found = await anchorProvider.findByIdentifier(anchorRun.identifier); + t.check("the rebuilt row is located again", Boolean(found)); + t.check("as a fresh object", found !== anchorRun); + t.equals("with the same identity", found?.identifier, anchorRun.identifier); + + anchorView.selection = []; + t.equals( + "a row inside a collapsed branch is not searched for", + await anchorProvider.findByIdentifier("step-9001-1"), + null, + ); + t.section("api paths exercised"); console.log([...new Set(fake.calls.map((call) => call.split("?")[0]))].sort().join("\n")); diff --git a/Tests/nova-stub.js b/Tests/nova-stub.js index 6c0e6c6..d84a4dc 100644 --- a/Tests/nova-stub.js +++ b/Tests/nova-stub.js @@ -70,6 +70,11 @@ global.TreeView = class TreeView { treeViews.push(this); } reload() { this.reloads += 1; return Promise.resolve(); } + reveal(element, options) { + this.revealed = { element, options }; + if (options && options.select) this.selection = [element]; + return Promise.resolve(); + } onDidExpandElement(cb) { this._onExpand.push(cb); return { dispose() {} }; } onDidCollapseElement(cb) { this._onCollapse.push(cb); return { dispose() {} }; } // Test helpers: pretend the user opened or closed a row.