add extensible plugin list dropdown with marketplace links

Add a new per-plugin actions dropdown to the plugin list and expose new HTML extension points inside it so plugins can inject custom dropdown entries while empty menus stay hidden. Also add a fire-and-forget JS hook after `plugins_list` loads, with `_plugin_installer` owning the cached marketplace index fetch and deep-link helper so matching installed plugins get a Marketplace action that opens the exact marketplace detail.

Alessandro committed Mar 16, 2026 at 15:44 UTC 32bc95da3963f7173a2584129e2d54be5d5b0d78
6 files changed +205 -34
plugins/_plugin_installer/extensions/webui/plugins-list-dropdown-end/marketplace-button.html new
+11
@@ -0,0 +1,11 @@
1 +<div x-data>
2 + <template x-if="$store.pluginInstallStore && plugin.marketplace?.key">
3 + <button type="button"
4 + class="dropdown-item"
5 + title="Open in Marketplace"
6 + @click="$store.pluginInstallStore.openMarketplaceDetailByKey(plugin.marketplace.key); actionsOpen = false">
7 + <span class="material-symbols-outlined">storefront</span>
8 + <span>Marketplace</span>
9 + </button>
10 + </template>
11 +</div>
plugins/_plugin_installer/extensions/webui/plugins_list_after_load/annotate-marketplace-links.js new
+46
@@ -0,0 +1,46 @@
1 +import { store as pluginInstallStore } from "../../../webui/pluginInstallStore.js";
2 +
3 +function getPluginName(plugin) {
4 + return typeof plugin?.name === "string" ? plugin.name.trim() : "";
5 +}
6 +
7 +function getMarketplaceMatch(plugin, marketplacePlugins) {
8 + const pluginName = getPluginName(plugin);
9 + if (!pluginName || !marketplacePlugins?.[pluginName]) {
10 + return null;
11 + }
12 +
13 + return {
14 + key: pluginName,
15 + title: marketplacePlugins[pluginName]?.title || pluginName,
16 + };
17 +}
18 +
19 +export default async function annotateMarketplaceLinks(context) {
20 + const plugins = Array.isArray(context?.plugins) ? context.plugins : null;
21 + const store = context?.store;
22 + if (!plugins?.length) return;
23 +
24 + const loaded = await pluginInstallStore.ensureIndexLoaded({ background: true });
25 + if (!loaded) return;
26 +
27 + const marketplacePlugins = pluginInstallStore.index?.plugins;
28 + if (!marketplacePlugins || typeof marketplacePlugins !== "object") return;
29 +
30 + let changed = false;
31 + for (const plugin of plugins) {
32 + if (!plugin || typeof plugin !== "object") continue;
33 +
34 + const nextMarketplace = getMarketplaceMatch(plugin, marketplacePlugins);
35 + const currentKey = plugin?.marketplace?.key || "";
36 + const nextKey = nextMarketplace?.key || "";
37 + if (currentKey === nextKey) continue;
38 +
39 + plugin.marketplace = nextMarketplace;
40 + changed = true;
41 + }
42 +
43 + if (changed && store?.plugins === plugins) {
44 + store.plugins = [...plugins];
45 + }
46 +}
plugins/_plugin_installer/webui/pluginInstallStore.js
+78 -8
@@ -38,6 +38,7 @@ const model = {
38
39 // Index state
40 index: { authors: {}, plugins: {} },
41 + indexLoadPromise: null,
42 installedPlugins: [],
43 installedPluginDetails: {},
44 search: "",
@@ -227,18 +228,34 @@ const model = {
228
229 // ── Index Browse ─────────────────────────────
230
230 - async fetchIndex() {
231 + hasIndexData() {
232 + const plugins = this.index?.plugins;
233 + return !!plugins && typeof plugins === "object" && Object.keys(plugins).length > 0;
234 + },
235 +
236 + async fetchIndex(options = {}) {
237 + const background = !!options?.background;
238 + const suppressErrors = !!options?.suppressErrors;
239 + if (this.indexLoadPromise) {
240 + return this.indexLoadPromise;
241 + }
242 +
243 + const loadPromise = (async () => {
244 try {
232 - this.loading = true;
233 - this.loadingMessage = "Loading plugin index...";
245 + if (!background) {
246 + this.loading = true;
247 + this.loadingMessage = "Loading plugin index...";
248 + }
249
250 const data = await api.callJsonApi(PLUGIN_API, {
251 action: "fetch_index",
252 });
253
254 if (!data.success) {
240 - void toastFrontendError(data.error || "Failed to load index", "Plugin Installer");
241 - return;
255 + if (!suppressErrors) {
256 + void toastFrontendError(data.error || "Failed to load index", "Plugin Installer");
257 + }
258 + return false;
259 }
260
261 this.index = data.index;
@@ -251,13 +268,40 @@ const model = {
268 installedList.map((plugin) => [plugin.name, plugin])
269 );
270 this.page = 1;
271 + return true;
272 } catch (e) {
273 const message = e instanceof Error ? e.message : String(e);
256 - void toastFrontendError(`Failed to load plugin index: ${message}`, "Plugin Installer");
274 + if (!suppressErrors) {
275 + void toastFrontendError(`Failed to load plugin index: ${message}`, "Plugin Installer");
276 + }
277 + return false;
278 } finally {
258 - this.loading = false;
259 - this.loadingMessage = "";
279 + if (!background) {
280 + this.loading = false;
281 + this.loadingMessage = "";
282 + }
283 + }
284 + })();
285 +
286 + this.indexLoadPromise = loadPromise.finally(() => {
287 + if (this.indexLoadPromise === loadPromise) {
288 + this.indexLoadPromise = null;
289 + }
290 + });
291 +
292 + return this.indexLoadPromise;
293 + },
294 +
295 + async ensureIndexLoaded(options = {}) {
296 + if (this.hasIndexData()) {
297 + return true;
298 }
299 +
300 + await this.fetchIndex({
301 + background: !!options?.background,
302 + suppressErrors: !!options?.background,
303 + });
304 + return this.hasIndexData();
305 },
306
307 get pluginsList() {
@@ -380,6 +424,32 @@ const model = {
424 this.page = Math.max(1, Math.min(p, this.totalPages));
425 },
426
427 + getMarketplacePluginByKey(pluginKey) {
428 + const key = typeof pluginKey === "string" ? pluginKey.trim() : "";
429 + if (!key) return null;
430 + return this.pluginsList.find((plugin) => plugin.key === key) || null;
431 + },
432 +
433 + async openMarketplaceDetailByKey(pluginKey) {
434 + const key = typeof pluginKey === "string" ? pluginKey.trim() : "";
435 + if (!key) return false;
436 +
437 + const loaded = await this.ensureIndexLoaded();
438 + if (!loaded) return false;
439 +
440 + const plugin = this.getMarketplacePluginByKey(key);
441 + if (!plugin) {
442 + void toastFrontendError(
443 + `Plugin "${key}" is not available in the marketplace index`,
444 + "Plugin Installer"
445 + );
446 + return false;
447 + }
448 +
449 + this.openDetail(plugin);
450 + return true;
451 + },
452 +
453 openDetail(plugin) {
454 this.selectedPlugin = { ...plugin, name: plugin?.key || "" };
455 this.result = null;
tests/test_webui_extension_surfaces.py
+2
@@ -48,6 +48,8 @@ SURFACE_SCENARIOS: list[tuple[str, str]] = [
48 ("welcome-actions-end", "webui/components/welcome/welcome-screen.html"),
49 ("welcome-banners-start", "webui/components/welcome/welcome-screen.html"),
50 ("welcome-banners-end", "webui/components/welcome/welcome-screen.html"),
51 + ("plugins-list-dropdown-start", "webui/components/plugins/list/plugin-list.html"),
52 + ("plugins-list-dropdown-end", "webui/components/plugins/list/plugin-list.html"),
53 ("modal-shell-start", "webui/js/modals.js"),
54 ("modal-shell-end", "webui/js/modals.js"),
55 ]
webui/components/plugins/list/plugin-list.html
+62 -26
@@ -90,7 +90,7 @@
90 <div class="plugin-title" x-text="plugin.display_name || plugin.name || '(unnamed plugin)'"></div>
91 <code class="plugin-path" x-text="plugin.path"></code>
92 </div>
93 - <div class="plugin-actions">
93 + <div class="plugin-actions" x-data="{ actionsOpen: false }">
94 <template x-if="plugin.has_main_screen">
95 <button type="button"
96 class="button"
@@ -107,44 +107,67 @@
107 <span class="icon material-symbols-outlined">settings</span> Config
108 </button>
109 </template>
110 - <template x-if="plugin.has_readme">
111 - <button type="button"
112 - class="button"
113 - title="README"
114 - @click="$store.pluginListStore.openPluginDoc(plugin, 'readme')">
115 - <span class="icon material-symbols-outlined">description</span> README
116 - </button>
117 - </template>
118 - <template x-if="plugin.has_license">
119 - <button type="button"
120 - class="button"
121 - title="LICENSE"
122 - @click="$store.pluginListStore.openPluginDoc(plugin, 'license')">
123 - <span class="icon material-symbols-outlined">gavel</span> License
124 - </button>
125 - </template>
110 <template x-if="plugin.has_init_script">
111 <button type="button"
112 class="button"
129 - title="Run initializer script"
113 + title="Execute"
114 @click="$store.pluginListStore.openPluginInit(plugin)">
131 - <span class="icon material-symbols-outlined">terminal</span> Init
115 + <span class="icon material-symbols-outlined">terminal</span> Execute
116 </button>
117 </template>
118 <button type="button"
135 - class="button"
119 + class="button icon-button"
120 title="Info"
121 + aria-label="Info"
122 @click="$store.pluginListStore.openPluginInfo(plugin)">
123 <span class="icon material-symbols-outlined">info</span>
124 </button>
140 - <template x-if="plugin.is_custom">
125 + <div class="dropdown plugin-actions-dropdown"
126 + @click.outside="actionsOpen = false"
127 + @keydown.escape.window="if (actionsOpen) actionsOpen = false">
128 <button type="button"
142 - class="button cancel icon-button"
143 - title="Delete"
144 - @click="$confirmClick($event, () => $store.pluginListStore.deletePlugin(plugin))">
145 - <span class="icon material-symbols-outlined">delete</span>
129 + class="btn-icon-action dropdown-trigger"
130 + title="More actions"
131 + aria-label="More actions"
132 + @click.stop="actionsOpen = !actionsOpen"
133 + :aria-expanded="actionsOpen.toString()">
134 + <span class="material-symbols-outlined">more_vert</span>
135 </button>
147 - </template>
136 + <div class="dropdown-menu"
137 + x-show="actionsOpen"
138 + x-transition
139 + style="display: none;">
140 + <x-extension id="plugins-list-dropdown-start"></x-extension>
141 + <template x-if="plugin.has_readme">
142 + <button type="button"
143 + class="dropdown-item"
144 + title="README"
145 + @click="$store.pluginListStore.openPluginDoc(plugin, 'readme'); actionsOpen = false">
146 + <span class="material-symbols-outlined">description</span>
147 + <span>README</span>
148 + </button>
149 + </template>
150 + <template x-if="plugin.has_license">
151 + <button type="button"
152 + class="dropdown-item"
153 + title="License"
154 + @click="$store.pluginListStore.openPluginDoc(plugin, 'license'); actionsOpen = false">
155 + <span class="material-symbols-outlined">gavel</span>
156 + <span>License</span>
157 + </button>
158 + </template>
159 + <template x-if="plugin.is_custom">
160 + <button type="button"
161 + class="dropdown-item"
162 + title="Delete"
163 + @click="$confirmClick($event, () => { $store.pluginListStore.deletePlugin(plugin); actionsOpen = false; })">
164 + <span class="material-symbols-outlined">delete</span>
165 + <span>Delete</span>
166 + </button>
167 + </template>
168 + <x-extension id="plugins-list-dropdown-end"></x-extension>
169 + </div>
170 + </div>
171 </div>
172 </div>
173
@@ -312,6 +335,19 @@
335 font-size: 1.1rem;
336 }
337
338 + .plugin-actions-dropdown {
339 + flex: 0 0 auto;
340 + }
341 +
342 + .plugin-actions-dropdown:not(:has(.dropdown-menu .dropdown-item)) {
343 + display: none;
344 + }
345 +
346 + .plugin-actions-dropdown .dropdown-menu {
347 + margin-top: 0;
348 + top: calc(100% - 1px);
349 + }
350 +
351 .plugin-footer-row {
352 display: flex;
353 align-items: center;
webui/components/plugins/list/pluginListStore.js
+6
@@ -4,6 +4,7 @@ import { store as pluginSettingsStore } from "/components/plugins/plugin-setting
4 import { store as pluginToggleStore } from "/components/plugins/toggle/plugin-toggle-store.js";
5 import { store as pluginInitStore } from "/components/plugins/list/plugin-init-store.js";
6 import { store as markdownModalStore } from "/components/modals/markdown/markdown-store.js";
7 +import { callJsExtensions } from "/js/extensions.js";
8 import {
9 store as notificationStore,
10 defaultPriority,
@@ -30,6 +31,11 @@ const model = {
31 try {
32 const response = await api.callJsonApi("plugins_list", { filter });
33 this.plugins = Array.isArray(response.plugins) ? response.plugins : [];
34 + void callJsExtensions("plugins_list_after_load", {
35 + filter: filter ? { ...filter } : null,
36 + plugins: this.plugins,
37 + store: this,
38 + });
39 } catch (e) {
40 this.plugins = [];
41 showErrorNotification(e, "Failed to load plugins list");