Plugins belong to the project, and they load before it: the project file gained the field plugins: ["demo-plugin.js"] (default empty, no formatVersion bump), the Plugins tab's switch writes into it as an ordinary undo step (SetPluginActiveCommand) instead of into a server-wide plugins.json, and the start order is turned around - startPlugins only fetches the list of files, then editor.boot() runs and ProjectStore.load(id, prepare) hands the raw JSON through before it is deserialized, where pluginsOf(raw) reads the list and PluginManager.syncTo() activates exactly those; that is the point of the issue, because what a plugin registers has to stand there before normalizeGraph would throw it away as unknown (#126, #127), a project switch first stops what the new project does not name and then starts what it does, a file the server does not have is passed over silently, and after signing out the manager is dead so a late event cannot start anything in a torn-down session; on the server plugins.json, PluginStore::setActive, isActive, the route PUT ?r=plugins/{file} and the 403 for inactive modules are gone and GET ?r=plugins only answers {file,bytes,updatedAt}, which makes every plugin file fetchable - the consequence accepted in #117, written down in the wiki for #115 and #116; publishPlugins(root) only copies files now, the seeder switches the demo plugin on in the fixture project, and the Plugins tab carries a puzzle piece as its icon while only a plugin's own tab still goes without one (the knob and the socket are cubic curves, not arcs, which Pixi's parser draws as a spike after an L); 3 new e2e checks in module 38 and 5 new unit tests cover the project as the owner of the switch, its undo, the order, pluginsOf against crude input and an unknown file name (#119)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c830c7a3aa
commit
5ba796a2b6
28 files changed
+425
-269
No files matched your search
@@ -57,9 +57,9 @@ class PluginRow extends HStack {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Plugins tab (#114): every file in `data/plugins/` with a switch. Switching one on loads and
|
||||
* activates it there and then, switching it off takes its tabs and listeners away again – the
|
||||
* state is the server's, so it holds for everyone who opens this editor.
|
||||
* The Plugins tab (#114, #119): every file in `data/plugins/` with a switch. Switching one on loads
|
||||
* and activates it there and then, switching it off takes its tabs and listeners away again – the
|
||||
* switch is part of the open project, so it travels with it and undo takes it back.
|
||||
*
|
||||
* A plugin that is off is never loaded, which is why its row has no name until it has run once:
|
||||
* reading a name out of it would mean running it.
|
||||
@@ -144,9 +144,7 @@ export class PluginsPane extends VStack {
|
||||
this.empty.visible = nothing;
|
||||
if (nothing && this.manager) this.empty.setText('No plugins in data/plugins');
|
||||
this.hint.setText(
|
||||
nothing
|
||||
? ''
|
||||
: 'Loaded from data/plugins, for everyone using this server. A new file stays off until switched on here.',
|
||||
nothing ? '' : 'Loaded from data/plugins. The switch belongs to this project and is an undo step.',
|
||||
);
|
||||
this.scroll.relayoutContent();
|
||||
this.relayout();
|
||||
|
||||
@@ -20,11 +20,10 @@ export interface PluginRecord {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A file as the server lists it. */
|
||||
/** A file as the server lists it. Whether it runs is the project's business, not the server's (#119). */
|
||||
export interface PluginEntry {
|
||||
file: string;
|
||||
updatedAt: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,8 +57,11 @@ export interface PluginLoaderDeps {
|
||||
load(file: string, updatedAt: number): Promise<unknown>;
|
||||
/** A fresh API for a plugin that passed the checks; what it registers goes into `scope`. */
|
||||
api(meta: PluginMeta, scope: PluginScope): unknown;
|
||||
/** Stores the switch on the server; throws when it was not stored. */
|
||||
store(file: string, active: boolean): Promise<void>;
|
||||
/**
|
||||
* Writes the switch into the project – an undo step (#119). Throws when it was not written, and
|
||||
* then nothing is switched.
|
||||
*/
|
||||
store(file: string, active: boolean): void;
|
||||
/** One line for the user about a plugin that could not be switched. */
|
||||
report(message: string): void;
|
||||
}
|
||||
@@ -73,6 +75,11 @@ function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** Both lists are sorted and without repeats when this is asked. */
|
||||
function sameFiles(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((file, i) => file === b[i]);
|
||||
}
|
||||
|
||||
/** The module's `meta` as the loader needs it, or why it cannot be used. */
|
||||
export function readMeta(module: unknown): PluginMeta | string {
|
||||
const exports = (typeof module === 'object' && module !== null ? module : {}) as Record<string, unknown>;
|
||||
@@ -94,9 +101,11 @@ export function readMeta(module: unknown): PluginMeta | string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugins of this editor and their switches (#113, #114). At start every switched-on plugin is
|
||||
* activated, one after the other in file-name order, so that what an earlier one registers (a tab
|
||||
* id, say) is settled before the next runs; one switched on later comes after all of them.
|
||||
* The plugins of this editor and their switches (#113, #114, #119). Which ones run is the open
|
||||
* project's business: `syncTo` takes the list out of the project file and makes the editor match
|
||||
* it, before the project itself is read. Plugins are activated one after the other in file-name
|
||||
* order, so that what an earlier one registers (a tab id, say) is settled before the next runs;
|
||||
* one switched on later comes after all of them.
|
||||
*
|
||||
* Nothing a plugin does wrong stops the others or the editor: a file that does not load, a module
|
||||
* that is not a plugin, an API version this editor does not offer, a second plugin of an id that
|
||||
@@ -110,8 +119,12 @@ export class PluginManager {
|
||||
private readonly entries = new Map<string, PluginEntry>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
/** The files the project last asked for, whether or not each of them made it to `active`. */
|
||||
private wanted: string[] = [];
|
||||
/** False until `start` has been through the list – the tab says "Loading…" until then. */
|
||||
started = false;
|
||||
/** Set by `stopAll`: this editor is going away, and nothing may start a plugin in it again. */
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly deps: PluginLoaderDeps) {}
|
||||
|
||||
@@ -125,14 +138,41 @@ export class PluginManager {
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes in the files the server has, all of them switched off. Nothing is loaded yet: what runs
|
||||
* is decided by the project, which is read next (#119).
|
||||
*/
|
||||
start(entries: readonly PluginEntry[]): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const sorted = [...entries].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
||||
for (const entry of sorted) this.entries.set(entry.file, entry);
|
||||
this.list = sorted.map((entry) => ({ file: entry.file, status: 'inactive', meta: null }));
|
||||
for (const entry of sorted) if (entry.active) await this.activate(entry.file);
|
||||
this.started = true;
|
||||
this.changed();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the running plugins match what a project asks for: those it does not name are switched
|
||||
* off first, so a plugin of the project being left has let go before the next one's plugins
|
||||
* register anything. A file the server does not have is passed over – it has no row either.
|
||||
*
|
||||
* Called for every project the editor opens, before the project is deserialized, and again for
|
||||
* every change of the list afterwards (the switch in the Plugins tab, and undo). Asking for what
|
||||
* is running already does nothing at all.
|
||||
*/
|
||||
syncTo(files: readonly string[]): Promise<void> {
|
||||
const wanted = [...new Set(files)].sort();
|
||||
if (this.stopped || (this.started && sameFiles(wanted, this.wanted))) return this.queue;
|
||||
this.wanted = wanted;
|
||||
return this.enqueue(async () => {
|
||||
// A plugin that failed counts as switched off here: it is no longer wanted, so its row goes
|
||||
// back to plain "Off", and naming it again is a fresh attempt.
|
||||
for (const record of this.list)
|
||||
if (!wanted.includes(record.file) && record.status !== 'inactive') this.deactivate(record.file);
|
||||
for (const file of wanted) if (this.record(file)?.status === 'inactive') await this.activate(file);
|
||||
this.changed();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,28 +184,32 @@ export class PluginManager {
|
||||
stopAll(): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
for (const file of [...this.loaded.keys()]) this.deactivate(file);
|
||||
this.wanted = [];
|
||||
this.started = false;
|
||||
this.stopped = true;
|
||||
this.changed();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/** Switches a plugin on or off: stored on the server first, and only then applied. */
|
||||
/**
|
||||
* The switch in the Plugins tab: written into the project first, and only then applied – so what
|
||||
* runs and what the project says can never come apart, and undo takes both back.
|
||||
*/
|
||||
setActive(file: string, active: boolean): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const record = this.record(file);
|
||||
if (!record || (record.status !== 'inactive') === active) return;
|
||||
try {
|
||||
await this.deps.store(file, active);
|
||||
} catch (error) {
|
||||
this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`);
|
||||
this.changed();
|
||||
return;
|
||||
}
|
||||
if (active) await this.activate(file);
|
||||
else this.deactivate(file);
|
||||
// Against the list asked for, not against the record: a second click while the first switch is
|
||||
// still running has to count, and the record only catches up when it is done.
|
||||
if (this.stopped || !this.record(file) || this.wanted.includes(file) === active) return this.queue;
|
||||
const rest = this.wanted.filter((f) => f !== file);
|
||||
const next = active ? [...rest, file].sort() : rest;
|
||||
try {
|
||||
this.deps.store(file, active);
|
||||
} catch (error) {
|
||||
this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`);
|
||||
this.changed();
|
||||
});
|
||||
return this.queue;
|
||||
}
|
||||
return this.syncTo(next);
|
||||
}
|
||||
|
||||
private async activate(file: string): Promise<void> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SetPluginActiveCommand } from '../../core/commands/assetCommands';
|
||||
import { apiRequest } from '../../core/store/http';
|
||||
import type { Editor } from '../Editor';
|
||||
import type { EditorState } from '../state';
|
||||
@@ -5,10 +6,14 @@ import { type PluginEntry, PluginManager } from './loader';
|
||||
import { createPluginApi, type PluginHost, scopedHost } from './pluginApi';
|
||||
|
||||
/**
|
||||
* Loads what is switched on in `data/plugins/` into the running editor (#113, #114). A plugin is an
|
||||
* Puts the plugins of `data/plugins/` at the editor's disposal (#113, #114, #119). A plugin is an
|
||||
* ES module with no imports of its own, fetched through the API route rather than as a static file
|
||||
* – so the folder cannot be used to reach anything but a plugin. A failure is a console line and a
|
||||
* notice; the editor goes on without that plugin.
|
||||
*
|
||||
* Nothing is loaded here: which plugins run is the open project's business, and this runs before
|
||||
* the first project is even fetched. `Editor.boot()` hands the manager the list out of the raw
|
||||
* project file, and from then on the project is followed – including undo of the switch.
|
||||
*/
|
||||
export async function startPlugins(endpoint: string, state: EditorState, editor: Editor): Promise<void> {
|
||||
const report = (message: string) => {
|
||||
@@ -37,15 +42,18 @@ export async function startPlugins(endpoint: string, state: EditorState, editor:
|
||||
load: (file, updatedAt) =>
|
||||
import(/* @vite-ignore */ `${endpoint}?r=plugins/${encodeURIComponent(file)}&v=${updatedAt}`),
|
||||
api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)),
|
||||
store: async (file, active) => {
|
||||
await apiRequest(endpoint, 'PUT', `plugins/${file}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active }),
|
||||
});
|
||||
// A refused command (a notice of its own) must not leave the plugin running against what the
|
||||
// project says, so it counts as "not stored".
|
||||
store: (file, active) => {
|
||||
if (!state.execute(new SetPluginActiveCommand(file, active)))
|
||||
throw new Error('the project did not take the switch');
|
||||
},
|
||||
report,
|
||||
});
|
||||
editor.plugins = manager;
|
||||
editor.pluginsPane.setManager(manager);
|
||||
// Undo, redo and a command of any other kind can move the list: the project decides, so the
|
||||
// editor follows it rather than remembering what it switched itself.
|
||||
state.on('project', () => void manager.syncTo(state.project.plugins));
|
||||
await manager.start(entries);
|
||||
}
|
||||
Reference in new issue
Block a user