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");