@plmbr/notebook-intelligence 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +412 -0
  3. package/lib/api.d.ts +288 -0
  4. package/lib/api.js +927 -0
  5. package/lib/cell-output-bundle.d.ts +25 -0
  6. package/lib/cell-output-bundle.js +129 -0
  7. package/lib/cell-output-toolbar.d.ts +26 -0
  8. package/lib/cell-output-toolbar.js +188 -0
  9. package/lib/chat-progress-feedback.d.ts +3 -0
  10. package/lib/chat-progress-feedback.js +27 -0
  11. package/lib/chat-sidebar.d.ts +92 -0
  12. package/lib/chat-sidebar.js +3452 -0
  13. package/lib/command-ids.d.ts +39 -0
  14. package/lib/command-ids.js +44 -0
  15. package/lib/components/ask-user-question.d.ts +2 -0
  16. package/lib/components/ask-user-question.js +85 -0
  17. package/lib/components/checkbox.d.ts +2 -0
  18. package/lib/components/checkbox.js +30 -0
  19. package/lib/components/claude-mcp-panel.d.ts +2 -0
  20. package/lib/components/claude-mcp-panel.js +275 -0
  21. package/lib/components/claude-mcp-paste.d.ts +7 -0
  22. package/lib/components/claude-mcp-paste.js +104 -0
  23. package/lib/components/claude-session-picker.d.ts +8 -0
  24. package/lib/components/claude-session-picker.js +127 -0
  25. package/lib/components/form-dialog.d.ts +25 -0
  26. package/lib/components/form-dialog.js +35 -0
  27. package/lib/components/launcher-picker.d.ts +6 -0
  28. package/lib/components/launcher-picker.js +135 -0
  29. package/lib/components/mcp-util.d.ts +2 -0
  30. package/lib/components/mcp-util.js +37 -0
  31. package/lib/components/notebook-generation-popover.d.ts +7 -0
  32. package/lib/components/notebook-generation-popover.js +60 -0
  33. package/lib/components/pill.d.ts +2 -0
  34. package/lib/components/pill.js +5 -0
  35. package/lib/components/plugins-panel.d.ts +3 -0
  36. package/lib/components/plugins-panel.js +466 -0
  37. package/lib/components/settings-panel.d.ts +11 -0
  38. package/lib/components/settings-panel.js +742 -0
  39. package/lib/components/skills-panel.d.ts +2 -0
  40. package/lib/components/skills-panel.js +1264 -0
  41. package/lib/handler.d.ts +8 -0
  42. package/lib/handler.js +36 -0
  43. package/lib/icons.d.ts +45 -0
  44. package/lib/icons.js +54 -0
  45. package/lib/index.d.ts +8 -0
  46. package/lib/index.js +2079 -0
  47. package/lib/markdown-renderer.d.ts +10 -0
  48. package/lib/markdown-renderer.js +64 -0
  49. package/lib/notebook-generation-toolbar.d.ts +16 -0
  50. package/lib/notebook-generation-toolbar.js +197 -0
  51. package/lib/notebook-generation.d.ts +8 -0
  52. package/lib/notebook-generation.js +12 -0
  53. package/lib/open-file-refresh-watcher-env.d.ts +4 -0
  54. package/lib/open-file-refresh-watcher-env.js +33 -0
  55. package/lib/open-file-refresh-watcher.d.ts +97 -0
  56. package/lib/open-file-refresh-watcher.js +190 -0
  57. package/lib/shell-utils.d.ts +6 -0
  58. package/lib/shell-utils.js +9 -0
  59. package/lib/task-target-notebook.d.ts +2 -0
  60. package/lib/task-target-notebook.js +28 -0
  61. package/lib/terminal-drag-format.d.ts +9 -0
  62. package/lib/terminal-drag-format.js +23 -0
  63. package/lib/terminal-drag.d.ts +12 -0
  64. package/lib/terminal-drag.js +268 -0
  65. package/lib/tokens.d.ts +149 -0
  66. package/lib/tokens.js +88 -0
  67. package/lib/tour/tour-anchors.d.ts +18 -0
  68. package/lib/tour/tour-anchors.js +18 -0
  69. package/lib/tour/tour-config.d.ts +66 -0
  70. package/lib/tour/tour-config.js +99 -0
  71. package/lib/tour/tour-defaults.json +58 -0
  72. package/lib/tour/tour-events.d.ts +19 -0
  73. package/lib/tour/tour-events.js +30 -0
  74. package/lib/tour/tour-overlay.d.ts +6 -0
  75. package/lib/tour/tour-overlay.js +350 -0
  76. package/lib/tour/tour-state.d.ts +20 -0
  77. package/lib/tour/tour-state.js +81 -0
  78. package/lib/tour/tour-steps.d.ts +33 -0
  79. package/lib/tour/tour-steps.js +216 -0
  80. package/lib/utils.d.ts +53 -0
  81. package/lib/utils.js +385 -0
  82. package/package.json +258 -0
  83. package/schema/plugin.json +42 -0
  84. package/src/api.ts +1424 -0
  85. package/src/cell-output-bundle.ts +176 -0
  86. package/src/cell-output-toolbar.ts +232 -0
  87. package/src/chat-progress-feedback.ts +35 -0
  88. package/src/chat-sidebar.tsx +5147 -0
  89. package/src/command-ids.ts +67 -0
  90. package/src/components/ask-user-question.tsx +151 -0
  91. package/src/components/checkbox.tsx +62 -0
  92. package/src/components/claude-mcp-panel.tsx +543 -0
  93. package/src/components/claude-mcp-paste.ts +132 -0
  94. package/src/components/claude-session-picker.tsx +214 -0
  95. package/src/components/form-dialog.tsx +75 -0
  96. package/src/components/launcher-picker.tsx +237 -0
  97. package/src/components/mcp-util.ts +53 -0
  98. package/src/components/notebook-generation-popover.tsx +127 -0
  99. package/src/components/pill.tsx +15 -0
  100. package/src/components/plugins-panel.tsx +774 -0
  101. package/src/components/settings-panel.tsx +1631 -0
  102. package/src/components/skills-panel.tsx +2084 -0
  103. package/src/handler.ts +51 -0
  104. package/src/icons.ts +71 -0
  105. package/src/index.ts +2583 -0
  106. package/src/markdown-renderer.tsx +153 -0
  107. package/src/notebook-generation-toolbar.tsx +281 -0
  108. package/src/notebook-generation.ts +23 -0
  109. package/src/open-file-refresh-watcher-env.ts +52 -0
  110. package/src/open-file-refresh-watcher.ts +260 -0
  111. package/src/shell-utils.ts +10 -0
  112. package/src/svg.d.ts +4 -0
  113. package/src/task-target-notebook.ts +37 -0
  114. package/src/terminal-drag-format.ts +29 -0
  115. package/src/terminal-drag.ts +382 -0
  116. package/src/tokens.ts +171 -0
  117. package/src/tour/tour-anchors.ts +21 -0
  118. package/src/tour/tour-config.ts +160 -0
  119. package/src/tour/tour-events.ts +34 -0
  120. package/src/tour/tour-overlay.tsx +474 -0
  121. package/src/tour/tour-state.ts +87 -0
  122. package/src/tour/tour-steps.ts +281 -0
  123. package/src/utils.ts +455 -0
  124. package/style/base.css +3238 -0
  125. package/style/icons/cell-toolbar-bug.svg +5 -0
  126. package/style/icons/cell-toolbar-chat.svg +5 -0
  127. package/style/icons/cell-toolbar-sparkle.svg +5 -0
  128. package/style/icons/claude.svg +1 -0
  129. package/style/icons/copilot-warning.svg +1 -0
  130. package/style/icons/copilot.svg +1 -0
  131. package/style/icons/copy.svg +1 -0
  132. package/style/icons/openai.svg +1 -0
  133. package/style/icons/opencode.svg +1 -0
  134. package/style/icons/sparkles-warning.svg +5 -0
  135. package/style/icons/sparkles.svg +1 -0
  136. package/style/index.css +1 -0
  137. package/style/index.js +1 -0
@@ -0,0 +1,774 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { Dialog, showDialog } from '@jupyterlab/apputils';
5
+ import {
6
+ IPluginInfo,
7
+ IPluginMarketplaceInfo,
8
+ IPluginMarketplacePluginInfo,
9
+ NBIAPI,
10
+ PluginScope
11
+ } from '../api';
12
+ import { FormDialog } from './form-dialog';
13
+
14
+ const SCOPES: PluginScope[] = ['user', 'project', 'local'];
15
+ const SCOPE_HINT: Record<PluginScope, string> = {
16
+ user: 'available in all your projects',
17
+ project: 'shared via the project repo',
18
+ local: 'this project, this user only'
19
+ };
20
+
21
+ function marketplaceName(marketplace: IPluginMarketplaceInfo): string {
22
+ return String(marketplace.name ?? '').trim();
23
+ }
24
+
25
+ // Plugin-name list trimmed with ellipsis. Caller decides the visible cap;
26
+ // the helper just stitches a comma-separated string and appends the
27
+ // "+N more" tail when the cap is hit, so the row width stays bounded
28
+ // even for marketplaces with hundreds of plugins.
29
+ export function summarizePluginNames(
30
+ names: readonly string[] | undefined,
31
+ visible: number
32
+ ): string {
33
+ if (!names || names.length === 0) {
34
+ return '';
35
+ }
36
+ if (names.length <= visible) {
37
+ return names.join(', ');
38
+ }
39
+ const head = names.slice(0, visible).join(', ');
40
+ const remaining = names.length - visible;
41
+ return `${head}, +${remaining} more`;
42
+ }
43
+
44
+ function pluginName(plugin: IPluginInfo): string {
45
+ return String(plugin.name ?? plugin.id ?? '').trim();
46
+ }
47
+
48
+ function pluginEntryLabel(plugin: IPluginMarketplacePluginInfo): string {
49
+ const name = pluginName(plugin);
50
+ const description =
51
+ typeof plugin.description === 'string' ? plugin.description : '';
52
+ return description ? `${name} - ${description}` : name;
53
+ }
54
+
55
+ type PluginInstallMode = 'marketplace' | 'manual';
56
+
57
+ export function SettingsPanelComponentPlugins(_props: any): JSX.Element {
58
+ const [plugins, setPlugins] = useState<IPluginInfo[]>([]);
59
+ const [marketplaces, setMarketplaces] = useState<IPluginMarketplaceInfo[]>(
60
+ []
61
+ );
62
+ const [loading, setLoading] = useState(true);
63
+ const [error, setError] = useState<string | null>(null);
64
+ const [installOpen, setInstallOpen] = useState(false);
65
+ const [marketplaceOpen, setMarketplaceOpen] = useState(false);
66
+ // Composite key (`scope:name`) so a user-scope plugin and a project-scope
67
+ // plugin sharing a name don't clobber each other's busy indicators.
68
+ const [busyPluginKey, setBusyPluginKey] = useState<string | null>(null);
69
+ const [busyMarketplace, setBusyMarketplace] = useState<string | null>(null);
70
+ const [allowGithubImport, setAllowGithubImport] = useState(
71
+ NBIAPI.config.allowGithubPluginImport
72
+ );
73
+
74
+ const refresh = async () => {
75
+ setLoading(true);
76
+ setError(null);
77
+ try {
78
+ const [p, m] = await Promise.all([
79
+ NBIAPI.listPlugins(),
80
+ NBIAPI.listPluginMarketplaces()
81
+ ]);
82
+ setPlugins(p);
83
+ setMarketplaces(m);
84
+ } catch (e: any) {
85
+ setError(e?.message ?? String(e));
86
+ } finally {
87
+ setLoading(false);
88
+ }
89
+ };
90
+
91
+ useEffect(() => {
92
+ refresh();
93
+ const handler = () => {
94
+ setAllowGithubImport(NBIAPI.config.allowGithubPluginImport);
95
+ };
96
+ NBIAPI.configChanged.connect(handler);
97
+ return () => {
98
+ NBIAPI.configChanged.disconnect(handler);
99
+ };
100
+ }, []);
101
+
102
+ const handleUninstall = async (p: IPluginInfo) => {
103
+ const name = pluginName(p);
104
+ const scope = (p.scope as PluginScope) ?? 'user';
105
+ if (!name) {
106
+ return;
107
+ }
108
+ const ok = await showDialog({
109
+ title: 'Uninstall plugin?',
110
+ body: `"${name}" will be removed from Claude's ${scope}-scope config.`,
111
+ buttons: [
112
+ Dialog.cancelButton(),
113
+ Dialog.warnButton({ label: 'Uninstall' })
114
+ ]
115
+ });
116
+ if (!ok.button.accept) {
117
+ return;
118
+ }
119
+ const busyKey = `${scope}:${name}`;
120
+ setBusyPluginKey(busyKey);
121
+ try {
122
+ await NBIAPI.uninstallPlugin(name, scope);
123
+ await refresh();
124
+ } catch (e: any) {
125
+ setError(`Failed to uninstall: ${e?.message ?? e}`);
126
+ } finally {
127
+ setBusyPluginKey(null);
128
+ }
129
+ };
130
+
131
+ const handleToggleEnabled = async (p: IPluginInfo) => {
132
+ const name = pluginName(p);
133
+ const scope = (p.scope as PluginScope) ?? 'user';
134
+ if (!name) {
135
+ return;
136
+ }
137
+ const busyKey = `${scope}:${name}`;
138
+ setBusyPluginKey(busyKey);
139
+ try {
140
+ await NBIAPI.setPluginEnabled(name, scope, !p.enabled);
141
+ await refresh();
142
+ } catch (e: any) {
143
+ setError(`Failed to update: ${e?.message ?? e}`);
144
+ } finally {
145
+ setBusyPluginKey(null);
146
+ }
147
+ };
148
+
149
+ const handleRemoveMarketplace = async (m: IPluginMarketplaceInfo) => {
150
+ const name = String(m.name ?? '');
151
+ if (!name) {
152
+ return;
153
+ }
154
+ const ok = await showDialog({
155
+ title: 'Remove marketplace?',
156
+ body: `"${name}" will be removed from Claude's plugin marketplaces.`,
157
+ buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Remove' })]
158
+ });
159
+ if (!ok.button.accept) {
160
+ return;
161
+ }
162
+ setBusyMarketplace(name);
163
+ try {
164
+ await NBIAPI.removePluginMarketplace(name);
165
+ await refresh();
166
+ } catch (e: any) {
167
+ setError(`Failed to remove: ${e?.message ?? e}`);
168
+ } finally {
169
+ setBusyMarketplace(null);
170
+ }
171
+ };
172
+
173
+ const handleUpdateMarketplace = async (m: IPluginMarketplaceInfo) => {
174
+ const name = String(m.name ?? '');
175
+ if (!name) {
176
+ return;
177
+ }
178
+ setBusyMarketplace(name);
179
+ try {
180
+ await NBIAPI.updatePluginMarketplace(name);
181
+ await refresh();
182
+ } catch (e: any) {
183
+ setError(`Failed to update marketplace: ${e?.message ?? e}`);
184
+ } finally {
185
+ setBusyMarketplace(null);
186
+ }
187
+ };
188
+
189
+ const grouped: Record<PluginScope, IPluginInfo[]> = {
190
+ user: [],
191
+ project: [],
192
+ local: []
193
+ };
194
+ for (const p of plugins) {
195
+ const scope = (p.scope as PluginScope) ?? 'user';
196
+ (grouped[scope] ?? grouped.user).push(p);
197
+ }
198
+
199
+ return (
200
+ <div className="config-dialog-body nbi-skills-panel">
201
+ <div className="nbi-skills-header">
202
+ <div className="nbi-skills-title">Plugins</div>
203
+ <div className="nbi-skills-header-actions">
204
+ <button
205
+ className="jp-Dialog-button jp-mod-reject jp-mod-styled"
206
+ onClick={refresh}
207
+ disabled={loading}
208
+ >
209
+ <div className="jp-Dialog-buttonLabel">
210
+ {loading ? 'Refreshing…' : 'Refresh'}
211
+ </div>
212
+ </button>
213
+ <button
214
+ className="jp-Dialog-button jp-mod-reject jp-mod-styled"
215
+ onClick={() => setMarketplaceOpen(true)}
216
+ >
217
+ <div className="jp-Dialog-buttonLabel">Add marketplace</div>
218
+ </button>
219
+ <button
220
+ className="jp-Dialog-button jp-mod-accept jp-mod-styled"
221
+ onClick={() => setInstallOpen(true)}
222
+ >
223
+ <div className="jp-Dialog-buttonLabel">Install plugin</div>
224
+ </button>
225
+ </div>
226
+ </div>
227
+
228
+ <div className="nbi-info-banner" role="note">
229
+ Add a marketplace to discover plugins, then install one to extend Claude
230
+ Code with new commands, agents, and tool integrations.
231
+ </div>
232
+
233
+ {error && (
234
+ <div className="nbi-skills-error" role="alert">
235
+ {error}
236
+ </div>
237
+ )}
238
+
239
+ <div className="nbi-skills-section">
240
+ <div className="nbi-skills-section-caption">MARKETPLACES</div>
241
+ {marketplaces.length === 0 ? (
242
+ <div className="nbi-skills-empty">
243
+ {loading
244
+ ? 'Loading…'
245
+ : 'No marketplaces configured. Add one to discover plugins.'}
246
+ </div>
247
+ ) : (
248
+ marketplaces.map((m, i) => (
249
+ <MarketplaceRow
250
+ key={String(m.name ?? m.source ?? i)}
251
+ info={m}
252
+ busy={busyMarketplace === String(m.name ?? '')}
253
+ onRemove={() => handleRemoveMarketplace(m)}
254
+ onUpdate={() => handleUpdateMarketplace(m)}
255
+ />
256
+ ))
257
+ )}
258
+ </div>
259
+
260
+ {SCOPES.map(scope => (
261
+ <PluginScopeSection
262
+ key={scope}
263
+ scope={scope}
264
+ plugins={grouped[scope]}
265
+ loading={loading}
266
+ busyPluginKey={busyPluginKey}
267
+ onUninstall={handleUninstall}
268
+ onToggle={handleToggleEnabled}
269
+ />
270
+ ))}
271
+
272
+ {installOpen && (
273
+ <PluginInstallDialog
274
+ marketplaces={marketplaces}
275
+ onCancel={() => setInstallOpen(false)}
276
+ onSubmit={async ({ plugin, scope }) => {
277
+ await NBIAPI.installPlugin(plugin, scope);
278
+ setInstallOpen(false);
279
+ await refresh();
280
+ }}
281
+ />
282
+ )}
283
+
284
+ {marketplaceOpen && (
285
+ <MarketplaceAddDialog
286
+ allowGithubImport={allowGithubImport}
287
+ onCancel={() => setMarketplaceOpen(false)}
288
+ onSubmit={async ({ source, scope }) => {
289
+ await NBIAPI.addPluginMarketplace(source, scope);
290
+ setMarketplaceOpen(false);
291
+ await refresh();
292
+ }}
293
+ />
294
+ )}
295
+ </div>
296
+ );
297
+ }
298
+
299
+ function PluginScopeSection(props: {
300
+ scope: PluginScope;
301
+ plugins: IPluginInfo[];
302
+ loading: boolean;
303
+ busyPluginKey: string | null;
304
+ onUninstall: (p: IPluginInfo) => void;
305
+ onToggle: (p: IPluginInfo) => void;
306
+ }) {
307
+ return (
308
+ <div className="nbi-skills-section">
309
+ <div
310
+ className="nbi-skills-section-caption"
311
+ title={SCOPE_HINT[props.scope]}
312
+ >
313
+ {props.scope.toUpperCase()}
314
+ </div>
315
+ {props.plugins.length === 0 ? (
316
+ <div className="nbi-skills-empty">
317
+ {props.loading ? 'Loading…' : 'No plugins in this scope.'}
318
+ </div>
319
+ ) : (
320
+ props.plugins.map(p => {
321
+ const scope = (p.scope as PluginScope) ?? props.scope;
322
+ const name = pluginName(p);
323
+ const rowKey = `${scope}:${name}`;
324
+ return (
325
+ <PluginRow
326
+ key={rowKey}
327
+ plugin={p}
328
+ busy={props.busyPluginKey === rowKey}
329
+ onUninstall={() => props.onUninstall(p)}
330
+ onToggle={() => props.onToggle(p)}
331
+ />
332
+ );
333
+ })
334
+ )}
335
+ </div>
336
+ );
337
+ }
338
+
339
+ function PluginRow(props: {
340
+ plugin: IPluginInfo;
341
+ busy: boolean;
342
+ onUninstall: () => void;
343
+ onToggle: () => void;
344
+ }) {
345
+ const { plugin } = props;
346
+ const description = [plugin.version, plugin.marketplace, plugin.description]
347
+ .filter(Boolean)
348
+ .map(String)
349
+ .join(' · ');
350
+ const enabled = plugin.enabled !== false;
351
+ return (
352
+ <div className="nbi-skill-row">
353
+ <div className="nbi-skill-row-main">
354
+ <div className="nbi-skill-row-name">
355
+ {pluginName(plugin) || '(unnamed)'}
356
+ {!enabled && <span> — disabled</span>}
357
+ </div>
358
+ {description && (
359
+ <div className="nbi-skill-row-description">{description}</div>
360
+ )}
361
+ </div>
362
+ <div className="nbi-skill-row-actions" onClick={e => e.stopPropagation()}>
363
+ <button
364
+ className="jp-Dialog-button jp-mod-reject jp-mod-styled"
365
+ onClick={props.onToggle}
366
+ disabled={props.busy}
367
+ >
368
+ <div className="jp-Dialog-buttonLabel">
369
+ {enabled ? 'Disable' : 'Enable'}
370
+ </div>
371
+ </button>
372
+ <button
373
+ className="jp-Dialog-button jp-mod-reject jp-mod-styled"
374
+ onClick={props.onUninstall}
375
+ disabled={props.busy}
376
+ >
377
+ <div className="jp-Dialog-buttonLabel">
378
+ {props.busy ? 'Working…' : 'Uninstall'}
379
+ </div>
380
+ </button>
381
+ </div>
382
+ </div>
383
+ );
384
+ }
385
+
386
+ // Visible plugin-name cap on the marketplace row. Picked so a typical
387
+ // marketplace (a handful of plugins) fits on one line, with longer lists
388
+ // collapsing to "a, b, c, +N more" so the row width stays bounded.
389
+ const MARKETPLACE_VISIBLE_PLUGINS = 5;
390
+
391
+ function MarketplaceRow(props: {
392
+ info: IPluginMarketplaceInfo;
393
+ busy: boolean;
394
+ onRemove: () => void;
395
+ onUpdate: () => void;
396
+ }) {
397
+ const { info } = props;
398
+ const description =
399
+ typeof info.description === 'string' ? info.description.trim() : '';
400
+ const version = typeof info.version === 'string' ? info.version.trim() : '';
401
+ const pluginNames = Array.isArray(info.plugin_names)
402
+ ? info.plugin_names.filter((n): n is string => typeof n === 'string')
403
+ : [];
404
+ const pluginCount =
405
+ typeof info.plugin_count === 'number'
406
+ ? info.plugin_count
407
+ : pluginNames.length;
408
+ const pluginSummary = summarizePluginNames(
409
+ pluginNames,
410
+ MARKETPLACE_VISIBLE_PLUGINS
411
+ );
412
+ // Pluralize for 0/1/N: "no plugins" reads better than "0 plugins" when
413
+ // the marketplace manifest has not yet been refreshed and we have no
414
+ // plugin entries.
415
+ const pluginCountLabel =
416
+ pluginCount === 0
417
+ ? 'no plugins'
418
+ : pluginCount === 1
419
+ ? '1 plugin'
420
+ : `${pluginCount} plugins`;
421
+ // Only render the plugin summary line when the manifest read produced
422
+ // a count or name list. Without this guard, a marketplace that the
423
+ // user just added but whose manifest hasn't been cached yet would
424
+ // render "no plugins" and look broken; here it just renders no line
425
+ // at all until the next refresh picks up the manifest.
426
+ const hasPluginManifestData =
427
+ Array.isArray(info.plugin_names) || typeof info.plugin_count === 'number';
428
+ return (
429
+ <div className="nbi-skill-row">
430
+ <div className="nbi-skill-row-main">
431
+ <div className="nbi-skill-row-name">
432
+ {String(info.name ?? '(unnamed)')}
433
+ {version && (
434
+ <span className="nbi-skill-row-version"> v{version}</span>
435
+ )}
436
+ </div>
437
+ {description && (
438
+ <div className="nbi-skill-row-description">{description}</div>
439
+ )}
440
+ {info.source && (
441
+ <div className="nbi-skill-row-description">
442
+ <code>{String(info.source)}</code>
443
+ </div>
444
+ )}
445
+ {hasPluginManifestData && (
446
+ <div className="nbi-skill-row-description nbi-skill-row-plugins">
447
+ {pluginCountLabel}
448
+ {pluginSummary && <span>{`: ${pluginSummary}`}</span>}
449
+ </div>
450
+ )}
451
+ </div>
452
+ <div className="nbi-skill-row-actions" onClick={e => e.stopPropagation()}>
453
+ <button
454
+ className="jp-Dialog-button jp-mod-accept jp-mod-styled"
455
+ onClick={props.onUpdate}
456
+ disabled={props.busy}
457
+ title="Refresh this marketplace from its source"
458
+ >
459
+ <div className="jp-Dialog-buttonLabel">
460
+ {props.busy ? 'Updating…' : 'Update'}
461
+ </div>
462
+ </button>
463
+ <button
464
+ className="jp-Dialog-button jp-mod-reject jp-mod-styled"
465
+ onClick={props.onRemove}
466
+ disabled={props.busy}
467
+ >
468
+ <div className="jp-Dialog-buttonLabel">
469
+ {props.busy ? 'Removing…' : 'Remove'}
470
+ </div>
471
+ </button>
472
+ </div>
473
+ </div>
474
+ );
475
+ }
476
+
477
+ function PluginInstallDialog(props: {
478
+ marketplaces: IPluginMarketplaceInfo[];
479
+ onCancel: () => void;
480
+ onSubmit: (input: { plugin: string; scope: PluginScope }) => Promise<void>;
481
+ }) {
482
+ const marketplaceNames = props.marketplaces
483
+ .map(marketplaceName)
484
+ .filter(Boolean);
485
+ const marketplaceKey = marketplaceNames.join('\n');
486
+ const [installMode, setInstallMode] = useState<PluginInstallMode>(
487
+ marketplaceNames.length > 0 ? 'marketplace' : 'manual'
488
+ );
489
+ const [marketplace, setMarketplace] = useState(marketplaceNames[0] ?? '');
490
+ const [marketplacePlugins, setMarketplacePlugins] = useState<
491
+ IPluginMarketplacePluginInfo[]
492
+ >([]);
493
+ const [selectedPlugin, setSelectedPlugin] = useState('');
494
+ const [manualPlugin, setManualPlugin] = useState('');
495
+ const [scope, setScope] = useState<PluginScope>('user');
496
+ const [loadingPlugins, setLoadingPlugins] = useState(false);
497
+ const [pluginListError, setPluginListError] = useState<string | null>(null);
498
+ const [submitting, setSubmitting] = useState(false);
499
+ const [submitError, setSubmitError] = useState<string | null>(null);
500
+
501
+ useEffect(() => {
502
+ if (marketplace && marketplaceNames.includes(marketplace)) {
503
+ return;
504
+ }
505
+ setMarketplace(marketplaceNames[0] ?? '');
506
+ }, [marketplace, marketplaceKey]);
507
+
508
+ useEffect(() => {
509
+ let cancelled = false;
510
+ if (installMode !== 'marketplace') {
511
+ setLoadingPlugins(false);
512
+ setPluginListError(null);
513
+ return;
514
+ }
515
+ if (!marketplace) {
516
+ setMarketplacePlugins([]);
517
+ setSelectedPlugin('');
518
+ setPluginListError(null);
519
+ return;
520
+ }
521
+
522
+ setLoadingPlugins(true);
523
+ setPluginListError(null);
524
+ NBIAPI.listPluginMarketplacePlugins(marketplace)
525
+ .then(plugins => {
526
+ if (cancelled) {
527
+ return;
528
+ }
529
+ const namedPlugins = plugins.filter(plugin => pluginName(plugin));
530
+ setMarketplacePlugins(namedPlugins);
531
+ const firstPluginName =
532
+ namedPlugins.length > 0 ? pluginName(namedPlugins[0]) : '';
533
+ setSelectedPlugin(current =>
534
+ namedPlugins.some(plugin => pluginName(plugin) === current)
535
+ ? current
536
+ : firstPluginName
537
+ );
538
+ })
539
+ .catch((e: any) => {
540
+ if (cancelled) {
541
+ return;
542
+ }
543
+ setMarketplacePlugins([]);
544
+ setSelectedPlugin('');
545
+ setPluginListError(`Failed to load plugins: ${e?.message ?? e}`);
546
+ })
547
+ .finally(() => {
548
+ if (!cancelled) {
549
+ setLoadingPlugins(false);
550
+ }
551
+ });
552
+
553
+ return () => {
554
+ cancelled = true;
555
+ };
556
+ }, [installMode, marketplace]);
557
+
558
+ const trimmedManualPlugin = manualPlugin.trim();
559
+ const canSubmit =
560
+ !submitting &&
561
+ (installMode === 'manual'
562
+ ? Boolean(trimmedManualPlugin)
563
+ : Boolean(marketplace && selectedPlugin && !loadingPlugins));
564
+
565
+ const handleSubmit = async () => {
566
+ if (!canSubmit) {
567
+ return;
568
+ }
569
+ setSubmitError(null);
570
+ setSubmitting(true);
571
+ try {
572
+ await props.onSubmit({
573
+ plugin:
574
+ installMode === 'manual'
575
+ ? trimmedManualPlugin
576
+ : `${selectedPlugin}@${marketplace}`,
577
+ scope
578
+ });
579
+ } catch (e: any) {
580
+ setSubmitError(e?.message ?? String(e));
581
+ } finally {
582
+ setSubmitting(false);
583
+ }
584
+ };
585
+
586
+ return (
587
+ <FormDialog
588
+ title="Install plugin"
589
+ submitLabel="Install"
590
+ submitInProgressLabel="Installing…"
591
+ canSubmit={Boolean(canSubmit)}
592
+ submitting={submitting}
593
+ error={submitError}
594
+ onCancel={props.onCancel}
595
+ onSubmit={handleSubmit}
596
+ >
597
+ <div className="nbi-form-field">
598
+ <label>Install from</label>
599
+ <select
600
+ value={installMode}
601
+ onChange={e => setInstallMode(e.target.value as PluginInstallMode)}
602
+ disabled={submitting}
603
+ autoFocus
604
+ >
605
+ <option value="marketplace" disabled={marketplaceNames.length === 0}>
606
+ Marketplace picker
607
+ </option>
608
+ <option value="manual">Specify manually</option>
609
+ </select>
610
+ </div>
611
+ {installMode === 'manual' ? (
612
+ <>
613
+ <div className="nbi-form-field">
614
+ <label>Plugin</label>
615
+ <input
616
+ type="text"
617
+ value={manualPlugin}
618
+ onChange={e => setManualPlugin(e.target.value)}
619
+ placeholder="plugin@marketplace"
620
+ disabled={submitting}
621
+ />
622
+ </div>
623
+ <div className="nbi-form-hint">
624
+ Enter a plugin name or the plugin@marketplace shorthand.
625
+ </div>
626
+ </>
627
+ ) : (
628
+ <>
629
+ <div className="nbi-form-field">
630
+ <label>Marketplace</label>
631
+ <select
632
+ value={marketplace}
633
+ onChange={e => setMarketplace(e.target.value)}
634
+ disabled={submitting}
635
+ >
636
+ {marketplaceNames.map(name => (
637
+ <option key={name} value={name}>
638
+ {name}
639
+ </option>
640
+ ))}
641
+ </select>
642
+ </div>
643
+ <div className="nbi-form-field">
644
+ <label>Plugin</label>
645
+ <select
646
+ value={selectedPlugin}
647
+ onChange={e => setSelectedPlugin(e.target.value)}
648
+ disabled={
649
+ submitting || loadingPlugins || marketplacePlugins.length === 0
650
+ }
651
+ >
652
+ {marketplacePlugins.map(plugin => {
653
+ const name = pluginName(plugin);
654
+ return (
655
+ <option key={name} value={name}>
656
+ {pluginEntryLabel(plugin)}
657
+ </option>
658
+ );
659
+ })}
660
+ </select>
661
+ </div>
662
+ {loadingPlugins && (
663
+ <div className="nbi-form-hint">Loading marketplace plugins...</div>
664
+ )}
665
+ {!loadingPlugins &&
666
+ !pluginListError &&
667
+ marketplacePlugins.length === 0 && (
668
+ <div className="nbi-form-hint">
669
+ No plugins were found in this marketplace.
670
+ </div>
671
+ )}
672
+ {pluginListError && (
673
+ <div className="nbi-skills-error" role="alert">
674
+ {pluginListError}
675
+ </div>
676
+ )}
677
+ </>
678
+ )}
679
+ <div className="nbi-form-field">
680
+ <label>Scope</label>
681
+ <select
682
+ value={scope}
683
+ onChange={e => setScope(e.target.value as PluginScope)}
684
+ >
685
+ {SCOPES.map(s => (
686
+ <option key={s} value={s}>
687
+ {s} — {SCOPE_HINT[s]}
688
+ </option>
689
+ ))}
690
+ </select>
691
+ </div>
692
+ </FormDialog>
693
+ );
694
+ }
695
+
696
+ function MarketplaceAddDialog(props: {
697
+ allowGithubImport: boolean;
698
+ onCancel: () => void;
699
+ onSubmit: (input: { source: string; scope: PluginScope }) => Promise<void>;
700
+ }) {
701
+ const [source, setSource] = useState('');
702
+ const [scope, setScope] = useState<PluginScope>('user');
703
+ const [submitting, setSubmitting] = useState(false);
704
+ const [submitError, setSubmitError] = useState<string | null>(null);
705
+
706
+ const canSubmit = source.trim() && !submitting;
707
+
708
+ const handleSubmit = async () => {
709
+ if (!canSubmit) {
710
+ return;
711
+ }
712
+ setSubmitError(null);
713
+ setSubmitting(true);
714
+ try {
715
+ await props.onSubmit({ source: source.trim(), scope });
716
+ } catch (e: any) {
717
+ setSubmitError(e?.message ?? String(e));
718
+ } finally {
719
+ setSubmitting(false);
720
+ }
721
+ };
722
+
723
+ return (
724
+ <FormDialog
725
+ title="Add plugin marketplace"
726
+ submitLabel="Add"
727
+ submitInProgressLabel="Adding…"
728
+ canSubmit={Boolean(canSubmit)}
729
+ submitting={submitting}
730
+ error={submitError}
731
+ onCancel={props.onCancel}
732
+ onSubmit={handleSubmit}
733
+ >
734
+ <div className="nbi-form-field">
735
+ <label>Source</label>
736
+ <input
737
+ type="text"
738
+ value={source}
739
+ onChange={e => setSource(e.target.value)}
740
+ placeholder={
741
+ props.allowGithubImport
742
+ ? 'owner/repo, https://github.com/owner/repo, or local path'
743
+ : 'https://… or local path'
744
+ }
745
+ autoFocus
746
+ />
747
+ </div>
748
+ {props.allowGithubImport ? (
749
+ <div className="nbi-form-hint">
750
+ Private GitHub sources work if <code>GITHUB_TOKEN</code> is set or{' '}
751
+ <code>gh auth login</code> is configured on the Jupyter server.
752
+ </div>
753
+ ) : (
754
+ <div className="nbi-form-hint">
755
+ GitHub-sourced marketplaces are disabled by your administrator. Use a
756
+ non-GitHub URL or a local filesystem path.
757
+ </div>
758
+ )}
759
+ <div className="nbi-form-field">
760
+ <label>Scope</label>
761
+ <select
762
+ value={scope}
763
+ onChange={e => setScope(e.target.value as PluginScope)}
764
+ >
765
+ {SCOPES.map(s => (
766
+ <option key={s} value={s}>
767
+ {s} — {SCOPE_HINT[s]}
768
+ </option>
769
+ ))}
770
+ </select>
771
+ </div>
772
+ </FormDialog>
773
+ );
774
+ }