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
268 lines
8.8 KiB
JavaScript
268 lines
8.8 KiB
JavaScript
//
|
|
// Entry point. Builds the store, the four sidebar sections, and the command
|
|
// handlers, then keeps the sections in step with the store.
|
|
//
|
|
|
|
const artifacts = require("./controllers/artifacts.js");
|
|
const auth = require("./controllers/auth.js");
|
|
const browser = require("./controllers/browser.js");
|
|
const log = require("./util/log.js");
|
|
const logs = require("./controllers/logs.js");
|
|
const pullRequests = require("./controllers/pullRequests.js");
|
|
const review = require("./controllers/review.js");
|
|
const runControl = require("./controllers/runControl.js");
|
|
const secretsVariables = require("./controllers/secretsVariables.js");
|
|
const selection = require("./controllers/selection.js");
|
|
const ui = require("./ui.js");
|
|
|
|
const { BranchProvider } = require("./views/branchView.js");
|
|
const { PullRequestsProvider } = require("./views/pullRequestsView.js");
|
|
const { ReviewCommentsController } = require("./controllers/reviewComments.js");
|
|
const { SettingsProvider } = require("./views/settingsView.js");
|
|
const { Store } = require("./store.js");
|
|
const { WorkflowsProvider } = require("./views/workflowsView.js");
|
|
|
|
const RELOAD_DEBOUNCE_MS = 150;
|
|
|
|
// Preferences that change which repositories exist, rather than only how they
|
|
// are displayed, force a full rediscovery.
|
|
const REDISCOVERY_KEYS = [
|
|
"gitea.baseUrl",
|
|
"gitea.instances",
|
|
"gitea.hostAliases",
|
|
"gitea.discovery.mode",
|
|
];
|
|
const DISPLAY_KEYS = [
|
|
"gitea.maxRunsPerRepo",
|
|
"gitea.maxJobsPerRun",
|
|
"gitea.branchFilter",
|
|
"gitea.branchFilter.custom",
|
|
];
|
|
|
|
let state = null;
|
|
|
|
function buildSections(store) {
|
|
const definitions = [
|
|
{ id: "gitea.branch", provider: new BranchProvider(store) },
|
|
{ id: "gitea.workflows", provider: new WorkflowsProvider(store) },
|
|
{ id: "gitea.pullRequests", provider: new PullRequestsProvider(store) },
|
|
{ id: "gitea.settings", provider: new SettingsProvider(store) },
|
|
];
|
|
|
|
return definitions.map((definition) => {
|
|
const view = new TreeView(definition.id, { dataProvider: definition.provider });
|
|
nova.subscriptions.add(view);
|
|
|
|
const section = Object.assign(definition, { view: view });
|
|
trackExpansion(section);
|
|
return section;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mirrors expansion back into the provider. Nova applies whatever collapsible
|
|
* state getTreeItem returns, so a reload closes anything the provider does not
|
|
* know is open.
|
|
*/
|
|
function trackExpansion(section) {
|
|
const expand = section.view.onDidExpandElement((element) =>
|
|
section.provider.setExpanded(element, true),
|
|
);
|
|
const collapse = section.view.onDidCollapseElement((element) =>
|
|
section.provider.setExpanded(element, false),
|
|
);
|
|
|
|
for (const subscription of [expand, collapse]) {
|
|
if (subscription) nova.subscriptions.add(subscription);
|
|
}
|
|
}
|
|
|
|
function registerCommands(store, refresh, reviewComments, redraw) {
|
|
auth.register(store, refresh);
|
|
logs.register(store);
|
|
artifacts.register(store);
|
|
runControl.register(store, refresh);
|
|
pullRequests.register(store, refresh);
|
|
review.register(store, refresh, reviewComments);
|
|
secretsVariables.register(store);
|
|
browser.register();
|
|
|
|
nova.commands.register("gitea.refresh", async () => {
|
|
// An explicit refresh always redraws, even if nothing came back
|
|
// different, so the command visibly does something.
|
|
redraw();
|
|
await refresh({ rediscover: true });
|
|
await reviewComments.reload({ force: true });
|
|
});
|
|
|
|
// The per-section refresh buttons all drive the same fetch; they exist so
|
|
// each section header has an affordance of its own.
|
|
for (const name of ["gitea.refreshRuns", "gitea.refreshPullRequests", "gitea.refreshSettings"]) {
|
|
nova.commands.register(name, () => refresh());
|
|
}
|
|
|
|
nova.commands.register("gitea.refreshRepo", async (argument) => {
|
|
const repo = await selection.resolveRepo(argument, {
|
|
prompt: "Refresh which repository?",
|
|
});
|
|
if (repo) await store.refreshRepo(repo.key);
|
|
});
|
|
}
|
|
|
|
function watchPreferences(refresh) {
|
|
const subscriptions = [];
|
|
|
|
for (const key of REDISCOVERY_KEYS) {
|
|
subscriptions.push(nova.config.onDidChange(key, () => refresh({ rediscover: true })));
|
|
}
|
|
for (const key of DISPLAY_KEYS) {
|
|
subscriptions.push(nova.config.onDidChange(key, () => refresh()));
|
|
if (nova.workspace && nova.workspace.config) {
|
|
subscriptions.push(nova.workspace.config.onDidChange(key, () => refresh()));
|
|
}
|
|
}
|
|
if (nova.workspace && nova.workspace.config) {
|
|
subscriptions.push(
|
|
nova.workspace.config.onDidChange("gitea.baseUrl", () =>
|
|
refresh({ rediscover: true }),
|
|
),
|
|
);
|
|
}
|
|
|
|
for (const subscription of subscriptions) {
|
|
if (subscription) nova.subscriptions.add(subscription);
|
|
}
|
|
}
|
|
|
|
exports.activate = function () {
|
|
const store = new Store();
|
|
const sections = buildSections(store);
|
|
const reviewComments = new ReviewCommentsController(store);
|
|
|
|
selection.configure(
|
|
store,
|
|
sections.map((section) => section.view),
|
|
);
|
|
|
|
let timer = null;
|
|
const signatures = new Map();
|
|
|
|
/** Forces the next change to rebuild every section from scratch. */
|
|
const redraw = () => {
|
|
signatures.clear();
|
|
for (const section of sections) section.provider.invalidate();
|
|
};
|
|
|
|
const scheduleReload = () => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
timer = null;
|
|
|
|
// 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;
|
|
|
|
signatures.set(section.id, signature);
|
|
reloadSection(section);
|
|
}
|
|
}, RELOAD_DEBOUNCE_MS);
|
|
};
|
|
|
|
const subscription = store.onDidChange(scheduleReload);
|
|
|
|
const refresh = (options) =>
|
|
store.refresh(options).catch((error) => {
|
|
log.error("refresh failed", String(error));
|
|
});
|
|
|
|
registerCommands(store, refresh, reviewComments, redraw);
|
|
watchPreferences(refresh);
|
|
|
|
state = {
|
|
store: store,
|
|
sections: sections,
|
|
reviewComments: reviewComments,
|
|
subscription: subscription,
|
|
cancelReload: () => {
|
|
if (timer) clearTimeout(timer);
|
|
},
|
|
};
|
|
|
|
log.info(`activated for ${store.router.baseUrls.join(", ") || "no configured instance"}`);
|
|
|
|
if (!store.router.baseUrls.length) {
|
|
ui.warn("Gitea: set an instance URL in the extension preferences to get started.");
|
|
return;
|
|
}
|
|
|
|
refresh({ rediscover: true }).then(() => reviewComments.reload({ force: true }));
|
|
};
|
|
|
|
exports.deactivate = function () {
|
|
if (!state) return;
|
|
state.cancelReload();
|
|
state.subscription.dispose();
|
|
state.reviewComments.dispose();
|
|
state.store.dispose();
|
|
state = null;
|
|
};
|