Reload only the rows that changed
CI / Tests (push) Successful in 11s
CI / Generated images (push) Successful in 41s

Refreshing rebuilt the whole tree, and TreeView.reload() with no
argument drops the scroll position. Watching a job meant the view jumped
every fifteen seconds.

The provider now reconciles instead of replacing. update() rebuilds from
the store, matches rows to the existing nodes by identifier, copies the
rendered fields onto them, and reports the topmost rows that actually
changed; main.js reloads just those. Node identity survives the merge,
which is what makes a targeted reload possible. An open row that fetches
its own children has that cache dropped so it refetches — the running-job
path. A job finishing now reloads one run's row and nothing else.

Sections also each describe what they draw, so a section only rebuilds
when its own content moved: run activity no longer disturbs the pull
request list, and an idle tree is left alone entirely. This does not slow
anything down — a section showing a run in progress still updates on
every poll, and contributes a time bucket so its elapsed clock keeps
moving.

A row set changing at the root still needs a whole-tree reload; there the
selected row is re-revealed afterwards, which is the closest thing to a
scroll anchor Nova exposes.

Adds seventeen checks: no-op updates reporting nothing, a finishing job
naming only its own row, node identity surviving, a new run reloading
just its workflow group, and a new workflow escalating to a full reload.
168 checks total.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MQuusXgZC2dzwpJJ1qhtti
This commit is contained in:
2026-08-28 20:30:17 -03:00
co-authored by Claude Opus 5
parent 4bc81b59e7
commit cf97f0dc2a
11 changed files with 496 additions and 97 deletions
+152 -10
View File
@@ -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"));