xtralab 0.10.0 → 0.11.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.
- package/README.md +23 -9
- package/lib/commandBar/index.d.ts +14 -0
- package/lib/commandBar/index.js +114 -0
- package/lib/fileBrowser/fileBrowser.js +6 -1
- package/lib/highlight/index.d.ts +15 -0
- package/lib/highlight/index.js +214 -0
- package/lib/index.js +13 -1
- package/lib/launcher/agents.d.ts +7 -0
- package/lib/launcher/agents.js +24 -0
- package/lib/launcher/editorRegistry.js +5 -3
- package/lib/launcher/editors.d.ts +8 -0
- package/lib/launcher/editors.js +17 -0
- package/lib/launcher/index.js +4 -0
- package/lib/launcher/schemaDefaults.d.ts +15 -0
- package/lib/launcher/schemaDefaults.js +44 -0
- package/lib/omnibox/files.d.ts +7 -0
- package/lib/omnibox/files.js +50 -0
- package/lib/omnibox/index.d.ts +14 -0
- package/lib/omnibox/index.js +83 -0
- package/lib/omnibox/model.d.ts +45 -0
- package/lib/omnibox/model.js +142 -0
- package/lib/omnibox/tokens.d.ts +23 -0
- package/lib/omnibox/tokens.js +11 -0
- package/lib/omnibox/widget.d.ts +36 -0
- package/lib/omnibox/widget.js +161 -0
- package/lib/searchReplace/index.d.ts +25 -0
- package/lib/searchReplace/index.js +68 -0
- package/lib/showOutput/index.d.ts +17 -0
- package/lib/showOutput/index.js +154 -0
- package/lib/terminals/index.d.ts +4 -0
- package/lib/terminals/index.js +65 -16
- package/lib/terminals/model.d.ts +56 -3
- package/lib/terminals/model.js +261 -5
- package/lib/terminals/widget.d.ts +3 -1
- package/lib/terminals/widget.js +11 -3
- package/lib/walkthrough/index.d.ts +14 -0
- package/lib/walkthrough/index.js +162 -0
- package/lib/walkthrough/panel.d.ts +54 -0
- package/lib/walkthrough/panel.js +98 -0
- package/package.json +1 -1
- package/schema/launcher.json +3 -2
- package/schema/terminals.json +15 -0
- package/src/commandBar/index.ts +165 -0
- package/src/fileBrowser/fileBrowser.tsx +8 -1
- package/src/highlight/index.ts +251 -0
- package/src/index.ts +13 -1
- package/src/launcher/agents.ts +25 -0
- package/src/launcher/editorRegistry.ts +8 -4
- package/src/launcher/editors.ts +18 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/schemaDefaults.ts +53 -0
- package/src/omnibox/files.ts +68 -0
- package/src/omnibox/index.ts +105 -0
- package/src/omnibox/model.ts +210 -0
- package/src/omnibox/tokens.ts +29 -0
- package/src/omnibox/widget.tsx +322 -0
- package/src/searchReplace/index.ts +86 -0
- package/src/showOutput/index.ts +199 -0
- package/src/terminals/index.ts +79 -16
- package/src/terminals/model.ts +304 -3
- package/src/terminals/widget.tsx +21 -4
- package/src/walkthrough/index.ts +202 -0
- package/src/walkthrough/panel.ts +152 -0
- package/style/commandBar.css +104 -0
- package/style/highlight.css +11 -0
- package/style/index.css +5 -0
- package/style/index.js +5 -0
- package/style/omnibox.css +120 -0
- package/style/showOutput.css +11 -0
- package/style/terminals.css +54 -10
- package/style/walkthrough.css +80 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { DocumentRegistry } from '@jupyterlab/docregistry';
|
|
2
|
+
import type { TranslationBundle } from '@jupyterlab/translation';
|
|
3
|
+
import { LabIcon, ReactWidget } from '@jupyterlab/ui-components';
|
|
4
|
+
import type { CommandRegistry } from '@lumino/commands';
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import type { IAgent } from '../launcher/agents';
|
|
8
|
+
|
|
9
|
+
import { computeSections, IOmniboxItem } from './model';
|
|
10
|
+
import { loadWorkspaceFiles } from './files';
|
|
11
|
+
|
|
12
|
+
/** DOM id of the results listbox, referenced by the input's `aria-controls`. */
|
|
13
|
+
const LIST_ID = 'jp-xtralab-Omnibox-list';
|
|
14
|
+
|
|
15
|
+
/** Stable DOM id for the result row at `index`, for `aria-activedescendant`. */
|
|
16
|
+
function optionId(index: number): string {
|
|
17
|
+
return `jp-xtralab-Omnibox-option-${index}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render `text` with the fuzzy-matched characters at `indices` wrapped in a
|
|
22
|
+
* highlight span, leaving the rest as plain text.
|
|
23
|
+
*/
|
|
24
|
+
function renderHighlight(
|
|
25
|
+
text: string,
|
|
26
|
+
indices: readonly number[]
|
|
27
|
+
): React.ReactNode {
|
|
28
|
+
if (indices.length === 0) {
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
const matched = new Set(indices);
|
|
32
|
+
const nodes: React.ReactNode[] = [];
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < text.length) {
|
|
35
|
+
const isMatch = matched.has(i);
|
|
36
|
+
let j = i;
|
|
37
|
+
while (j < text.length && matched.has(j) === isMatch) {
|
|
38
|
+
j++;
|
|
39
|
+
}
|
|
40
|
+
const chunk = text.slice(i, j);
|
|
41
|
+
nodes.push(
|
|
42
|
+
isMatch ? (
|
|
43
|
+
<span className="jp-xtralab-Omnibox-match" key={i}>
|
|
44
|
+
{chunk}
|
|
45
|
+
</span>
|
|
46
|
+
) : (
|
|
47
|
+
<React.Fragment key={i}>{chunk}</React.Fragment>
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
i = j;
|
|
51
|
+
}
|
|
52
|
+
return nodes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderIcon(icon?: LabIcon): React.ReactNode {
|
|
56
|
+
return icon ? (
|
|
57
|
+
<icon.react tag="span" className="jp-xtralab-Omnibox-itemIcon" />
|
|
58
|
+
) : (
|
|
59
|
+
<span className="jp-xtralab-Omnibox-itemIcon" />
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function OmniboxComponent(props: OmniboxWidget.IOptions): JSX.Element {
|
|
64
|
+
const {
|
|
65
|
+
commands,
|
|
66
|
+
docRegistry,
|
|
67
|
+
agents,
|
|
68
|
+
placeholder,
|
|
69
|
+
initialQuery,
|
|
70
|
+
trans,
|
|
71
|
+
onClose
|
|
72
|
+
} = props;
|
|
73
|
+
const [query, setQuery] = React.useState(initialQuery);
|
|
74
|
+
const [files, setFiles] = React.useState<string[]>([]);
|
|
75
|
+
const [active, setActive] = React.useState(0);
|
|
76
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
77
|
+
const listRef = React.useRef<HTMLDivElement>(null);
|
|
78
|
+
|
|
79
|
+
React.useEffect(() => {
|
|
80
|
+
inputRef.current?.focus();
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
let cancelled = false;
|
|
85
|
+
void loadWorkspaceFiles().then(loaded => {
|
|
86
|
+
if (!cancelled) {
|
|
87
|
+
setFiles(loaded);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return () => {
|
|
91
|
+
cancelled = true;
|
|
92
|
+
};
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
const sections = React.useMemo(
|
|
96
|
+
() =>
|
|
97
|
+
computeSections({ query, commands, docRegistry, agents, files, trans }),
|
|
98
|
+
[query, commands, docRegistry, agents, files, trans]
|
|
99
|
+
);
|
|
100
|
+
const flat = React.useMemo(
|
|
101
|
+
() => [...sections.commands, ...sections.files, ...sections.agents],
|
|
102
|
+
[sections]
|
|
103
|
+
);
|
|
104
|
+
const hasResults = flat.length > 0;
|
|
105
|
+
|
|
106
|
+
// Reset the highlighted row whenever the result set changes, and keep the
|
|
107
|
+
// index in range when results shrink.
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
setActive(current => (current < flat.length ? current : 0));
|
|
110
|
+
}, [flat]);
|
|
111
|
+
|
|
112
|
+
// Keep the highlighted row scrolled into view as it moves.
|
|
113
|
+
React.useEffect(() => {
|
|
114
|
+
listRef.current
|
|
115
|
+
?.querySelector('.jp-mod-active')
|
|
116
|
+
?.scrollIntoView({ block: 'nearest' });
|
|
117
|
+
}, [active, flat]);
|
|
118
|
+
|
|
119
|
+
const dismiss = React.useCallback(() => {
|
|
120
|
+
// Defer so unmounting this React root (via onClose) never happens
|
|
121
|
+
// synchronously inside the event handler that triggered it.
|
|
122
|
+
window.setTimeout(onClose, 0);
|
|
123
|
+
}, [onClose]);
|
|
124
|
+
|
|
125
|
+
const run = React.useCallback(
|
|
126
|
+
(item: IOmniboxItem) => {
|
|
127
|
+
window.setTimeout(() => {
|
|
128
|
+
onClose();
|
|
129
|
+
item.execute();
|
|
130
|
+
}, 0);
|
|
131
|
+
},
|
|
132
|
+
[onClose]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const onKeyDown = (event: React.KeyboardEvent): void => {
|
|
136
|
+
switch (event.key) {
|
|
137
|
+
case 'ArrowDown':
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
event.stopPropagation();
|
|
140
|
+
setActive(current => Math.min(current + 1, flat.length - 1));
|
|
141
|
+
break;
|
|
142
|
+
case 'ArrowUp':
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
event.stopPropagation();
|
|
145
|
+
setActive(current => Math.max(current - 1, 0));
|
|
146
|
+
break;
|
|
147
|
+
case 'Enter': {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
event.stopPropagation();
|
|
150
|
+
const item = flat[active];
|
|
151
|
+
if (item) {
|
|
152
|
+
run(item);
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case 'Escape':
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
event.stopPropagation();
|
|
159
|
+
dismiss();
|
|
160
|
+
break;
|
|
161
|
+
default:
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const renderSection = (
|
|
167
|
+
title: string,
|
|
168
|
+
items: IOmniboxItem[],
|
|
169
|
+
startIndex: number
|
|
170
|
+
): React.ReactNode => {
|
|
171
|
+
if (items.length === 0) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return (
|
|
175
|
+
<div
|
|
176
|
+
className="jp-xtralab-Omnibox-section"
|
|
177
|
+
role="group"
|
|
178
|
+
aria-label={title}
|
|
179
|
+
key={title}
|
|
180
|
+
>
|
|
181
|
+
<div className="jp-xtralab-Omnibox-sectionHeader" aria-hidden="true">
|
|
182
|
+
{title}
|
|
183
|
+
</div>
|
|
184
|
+
{items.map((item, offset) => {
|
|
185
|
+
const index = startIndex + offset;
|
|
186
|
+
const isActive = index === active;
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
key={item.key}
|
|
190
|
+
id={optionId(index)}
|
|
191
|
+
role="option"
|
|
192
|
+
aria-selected={isActive}
|
|
193
|
+
className={
|
|
194
|
+
'jp-xtralab-Omnibox-item' + (isActive ? ' jp-mod-active' : '')
|
|
195
|
+
}
|
|
196
|
+
onMouseEnter={() => setActive(index)}
|
|
197
|
+
onClick={() => run(item)}
|
|
198
|
+
>
|
|
199
|
+
{renderIcon(item.icon)}
|
|
200
|
+
<span className="jp-xtralab-Omnibox-itemLabel">
|
|
201
|
+
{renderHighlight(item.label, item.matchIndices)}
|
|
202
|
+
</span>
|
|
203
|
+
{item.caption ? (
|
|
204
|
+
<span className="jp-xtralab-Omnibox-itemCaption">
|
|
205
|
+
{item.caption}
|
|
206
|
+
</span>
|
|
207
|
+
) : null}
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
})}
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const filesStart = sections.commands.length;
|
|
216
|
+
const agentsStart = filesStart + sections.files.length;
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div
|
|
220
|
+
className="jp-xtralab-Omnibox-overlay"
|
|
221
|
+
onMouseDown={event => {
|
|
222
|
+
if (event.target === event.currentTarget) {
|
|
223
|
+
dismiss();
|
|
224
|
+
}
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<div
|
|
228
|
+
className="jp-xtralab-Omnibox-panel"
|
|
229
|
+
role="dialog"
|
|
230
|
+
aria-modal="true"
|
|
231
|
+
aria-label={placeholder}
|
|
232
|
+
onMouseDown={event => event.stopPropagation()}
|
|
233
|
+
>
|
|
234
|
+
<input
|
|
235
|
+
ref={inputRef}
|
|
236
|
+
className="jp-xtralab-Omnibox-input"
|
|
237
|
+
type="text"
|
|
238
|
+
spellCheck={false}
|
|
239
|
+
placeholder={placeholder}
|
|
240
|
+
aria-label={placeholder}
|
|
241
|
+
role="combobox"
|
|
242
|
+
aria-autocomplete="list"
|
|
243
|
+
aria-expanded={hasResults}
|
|
244
|
+
aria-controls={LIST_ID}
|
|
245
|
+
aria-activedescendant={hasResults ? optionId(active) : undefined}
|
|
246
|
+
value={query}
|
|
247
|
+
onChange={event => setQuery(event.target.value)}
|
|
248
|
+
onKeyDown={onKeyDown}
|
|
249
|
+
/>
|
|
250
|
+
<div
|
|
251
|
+
className="jp-xtralab-Omnibox-results"
|
|
252
|
+
id={LIST_ID}
|
|
253
|
+
role={hasResults ? 'listbox' : undefined}
|
|
254
|
+
ref={listRef}
|
|
255
|
+
>
|
|
256
|
+
{hasResults ? (
|
|
257
|
+
<>
|
|
258
|
+
{renderSection(trans.__('Commands'), sections.commands, 0)}
|
|
259
|
+
{renderSection(trans.__('Files'), sections.files, filesStart)}
|
|
260
|
+
{renderSection(
|
|
261
|
+
trans.__('Ask an agent'),
|
|
262
|
+
sections.agents,
|
|
263
|
+
agentsStart
|
|
264
|
+
)}
|
|
265
|
+
</>
|
|
266
|
+
) : (
|
|
267
|
+
<div className="jp-xtralab-Omnibox-empty">
|
|
268
|
+
{query.trim()
|
|
269
|
+
? trans.__('No matches.')
|
|
270
|
+
: trans.__(
|
|
271
|
+
'Search files and commands, or type a prompt and pick an agent. Prefix with > for commands or / for files.'
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The omnibox overlay widget. Hosts {@link OmniboxComponent} in a React root
|
|
283
|
+
* attached to `document.body`. It carries `jp-ThemedContainer` so JupyterLab's
|
|
284
|
+
* theme CSS variables resolve outside the shell, and its own `jp-xtralab-Omnibox`
|
|
285
|
+
* class for the overlay/panel styling (style/omnibox.css).
|
|
286
|
+
*/
|
|
287
|
+
export class OmniboxWidget extends ReactWidget {
|
|
288
|
+
constructor(options: OmniboxWidget.IOptions) {
|
|
289
|
+
super();
|
|
290
|
+
this._options = options;
|
|
291
|
+
this.id = 'xtralab-omnibox';
|
|
292
|
+
this.addClass('jp-xtralab-Omnibox');
|
|
293
|
+
this.addClass('jp-ThemedContainer');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
render(): JSX.Element {
|
|
297
|
+
return <OmniboxComponent {...this._options} />;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private _options: OmniboxWidget.IOptions;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A namespace for `OmniboxWidget` statics.
|
|
305
|
+
*/
|
|
306
|
+
export namespace OmniboxWidget {
|
|
307
|
+
/** Construction options for {@link OmniboxWidget}. */
|
|
308
|
+
export interface IOptions {
|
|
309
|
+
commands: CommandRegistry;
|
|
310
|
+
docRegistry: DocumentRegistry;
|
|
311
|
+
/** Snapshot of the available agents, read once when the overlay opens. */
|
|
312
|
+
agents: IAgent[];
|
|
313
|
+
/** Placeholder text for the input. */
|
|
314
|
+
placeholder: string;
|
|
315
|
+
/** Seed text for the input. */
|
|
316
|
+
initialQuery: string;
|
|
317
|
+
/** Translation bundle for the overlay's own labels. */
|
|
318
|
+
trans: TranslationBundle;
|
|
319
|
+
/** Dismiss the overlay (the plugin disposes the widget). */
|
|
320
|
+
onClose: () => void;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ILabShell,
|
|
3
|
+
JupyterFrontEnd,
|
|
4
|
+
JupyterFrontEndPlugin
|
|
5
|
+
} from '@jupyterlab/application';
|
|
6
|
+
import { ICommandPalette } from '@jupyterlab/apputils';
|
|
7
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
8
|
+
|
|
9
|
+
const PLUGIN_ID = 'xtralab:search-replace';
|
|
10
|
+
|
|
11
|
+
const COMMAND_ID = 'xtralab:activate-search-replace';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Widget id of the panel contributed by `jupyterlab-search-replace`. The
|
|
15
|
+
* extension adds it to the left sidebar but registers no command of its own,
|
|
16
|
+
* so there is nothing to bind a shortcut to out of the box — this plugin adds
|
|
17
|
+
* the missing command.
|
|
18
|
+
*/
|
|
19
|
+
const SEARCH_REPLACE_WIDGET_ID = 'jp-search-replace';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bind Accel+Shift+F (Cmd+Shift+F on macOS, Ctrl+Shift+F elsewhere) to the
|
|
23
|
+
* project-wide Search and Replace panel, matching the equivalent shortcut in
|
|
24
|
+
* editors like VS Code.
|
|
25
|
+
*
|
|
26
|
+
* Two pieces are needed because that chord is already taken: JupyterLab core
|
|
27
|
+
* binds Accel+Shift+F to `filebrowser:toggle-main`.
|
|
28
|
+
*
|
|
29
|
+
* 1. The chord is freed declaratively. `SettingRegistry.reconcileShortcuts`
|
|
30
|
+
* keys collisions and disables on (keys, selector) — not on command — so the
|
|
31
|
+
* `@jupyterlab/shortcuts-extension:shortcuts` override in
|
|
32
|
+
* `default_setting_overrides.d/00-xtralab.json` disables the file-browser
|
|
33
|
+
* binding. The file browser stays reachable from its sidebar tab and the
|
|
34
|
+
* View menu; only the keyboard shortcut moves.
|
|
35
|
+
*
|
|
36
|
+
* 2. The new binding is added imperatively below rather than as another shipped
|
|
37
|
+
* default. A `disabled` default and a re-binding default land on the same
|
|
38
|
+
* (keys, selector) slot, and the disable suppresses every default on that
|
|
39
|
+
* slot — including the re-binding. `commands.addKeyBinding` runs outside the
|
|
40
|
+
* reconcile pass, so it is unaffected and wins cleanly once the file-browser
|
|
41
|
+
* binding is gone.
|
|
42
|
+
*/
|
|
43
|
+
const plugin: JupyterFrontEndPlugin<void> = {
|
|
44
|
+
id: PLUGIN_ID,
|
|
45
|
+
description:
|
|
46
|
+
'Activate the Search and Replace panel with Accel+Shift+F (Cmd/Ctrl+Shift+F).',
|
|
47
|
+
autoStart: true,
|
|
48
|
+
requires: [ILabShell],
|
|
49
|
+
optional: [ICommandPalette, ITranslator],
|
|
50
|
+
activate: (
|
|
51
|
+
app: JupyterFrontEnd,
|
|
52
|
+
labShell: ILabShell,
|
|
53
|
+
palette: ICommandPalette | null,
|
|
54
|
+
translator: ITranslator | null
|
|
55
|
+
): void => {
|
|
56
|
+
const trans = (translator ?? nullTranslator).load('jupyterlab');
|
|
57
|
+
const { commands } = app;
|
|
58
|
+
|
|
59
|
+
commands.addCommand(COMMAND_ID, {
|
|
60
|
+
label: trans.__('Search and Replace'),
|
|
61
|
+
caption: trans.__('Show the Search and Replace panel'),
|
|
62
|
+
execute: () => {
|
|
63
|
+
// `activateById` expands the left sidebar when collapsed, selects the
|
|
64
|
+
// tab, and the widget focuses its search input from `onAfterShow`, so
|
|
65
|
+
// the user can start typing a query immediately. It no-ops if
|
|
66
|
+
// `jupyterlab-search-replace` is unavailable.
|
|
67
|
+
labShell.activateById(SEARCH_REPLACE_WIDGET_ID);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
commands.addKeyBinding({
|
|
72
|
+
command: COMMAND_ID,
|
|
73
|
+
keys: ['Accel Shift F'],
|
|
74
|
+
selector: 'body'
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (palette) {
|
|
78
|
+
palette.addItem({
|
|
79
|
+
command: COMMAND_ID,
|
|
80
|
+
category: trans.__('File Operations')
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export default plugin;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ILabShell,
|
|
3
|
+
JupyterFrontEnd,
|
|
4
|
+
JupyterFrontEndPlugin
|
|
5
|
+
} from '@jupyterlab/application';
|
|
6
|
+
import { ICommandPalette } from '@jupyterlab/apputils';
|
|
7
|
+
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
|
|
8
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
9
|
+
import type { ReadonlyPartialJSONValue } from '@lumino/coreutils';
|
|
10
|
+
import { Panel } from '@lumino/widgets';
|
|
11
|
+
|
|
12
|
+
const PLUGIN_ID = 'xtralab:show-output';
|
|
13
|
+
|
|
14
|
+
/** Render arbitrary rich content into a panel, with no notebook and no kernel. */
|
|
15
|
+
const SHOW_COMMAND = 'xtralab:show';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a JSON-typed value that may have arrived as a string. Agents and the
|
|
19
|
+
* MCP bridge sometimes serialize a spec (e.g. a Vega-Lite object) to a string;
|
|
20
|
+
* for `application/...json` MIME types we accept either and parse the string.
|
|
21
|
+
*/
|
|
22
|
+
function coerceData(
|
|
23
|
+
mimeType: string,
|
|
24
|
+
data: ReadonlyPartialJSONValue
|
|
25
|
+
): ReadonlyPartialJSONValue {
|
|
26
|
+
if (typeof data === 'string' && /\+?json$/.test(mimeType)) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(data) as ReadonlyPartialJSONValue;
|
|
29
|
+
} catch {
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Contribute `xtralab:show`: render a single MIME bundle into a main-area
|
|
38
|
+
* panel using the application's render-mime registry.
|
|
39
|
+
*
|
|
40
|
+
* This is the kernel-free counterpart to running a notebook. A coding agent
|
|
41
|
+
* generates the content itself (a Vega-Lite spec, a Markdown explainer, an
|
|
42
|
+
* HTML fragment, an SVG, a base64 image) and hands it to this command, which
|
|
43
|
+
* renders it with the same renderers JupyterLab uses for cell output and docks
|
|
44
|
+
* the result beside the code. Nothing is written to disk and no kernel is
|
|
45
|
+
* required, so it works even where the kernel has no plotting libraries.
|
|
46
|
+
*
|
|
47
|
+
* Repeated calls with the same `id` reuse one panel, so a walkthrough can
|
|
48
|
+
* refresh the view in place instead of piling up tabs.
|
|
49
|
+
*/
|
|
50
|
+
const plugin: JupyterFrontEndPlugin<void> = {
|
|
51
|
+
id: PLUGIN_ID,
|
|
52
|
+
description:
|
|
53
|
+
'Render rich content (Markdown, Vega-Lite, HTML, images) into a panel.',
|
|
54
|
+
autoStart: true,
|
|
55
|
+
requires: [IRenderMimeRegistry, ILabShell],
|
|
56
|
+
optional: [ICommandPalette, ITranslator],
|
|
57
|
+
activate: (
|
|
58
|
+
app: JupyterFrontEnd,
|
|
59
|
+
rendermime: IRenderMimeRegistry,
|
|
60
|
+
labShell: ILabShell,
|
|
61
|
+
palette: ICommandPalette | null,
|
|
62
|
+
translator: ITranslator | null
|
|
63
|
+
): void => {
|
|
64
|
+
const { commands } = app;
|
|
65
|
+
const trans = (translator ?? nullTranslator).load('jupyterlab');
|
|
66
|
+
|
|
67
|
+
// One reusable panel per `id`, so repeated shows refresh in place.
|
|
68
|
+
const panels = new Map<string, Panel>();
|
|
69
|
+
|
|
70
|
+
commands.addCommand(SHOW_COMMAND, {
|
|
71
|
+
label: trans.__('Show Rich Output'),
|
|
72
|
+
caption: trans.__(
|
|
73
|
+
'Render Markdown, a Vega-Lite chart, HTML, or an image in a panel'
|
|
74
|
+
),
|
|
75
|
+
describedBy: {
|
|
76
|
+
args: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
required: ['mimeType', 'data'],
|
|
79
|
+
properties: {
|
|
80
|
+
mimeType: {
|
|
81
|
+
type: 'string',
|
|
82
|
+
description:
|
|
83
|
+
'MIME type to render, e.g. "text/markdown", "application/vnd.vegalite.v5+json", "text/html", "image/svg+xml", "image/png".'
|
|
84
|
+
},
|
|
85
|
+
data: {
|
|
86
|
+
description:
|
|
87
|
+
'The content. A string for text/image types (base64 for "image/png"); an object for JSON types such as a Vega-Lite spec.'
|
|
88
|
+
},
|
|
89
|
+
label: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
description: 'Title for the panel tab. Defaults to "Output".'
|
|
92
|
+
},
|
|
93
|
+
id: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description:
|
|
96
|
+
'Panel identifier. Calls sharing an `id` reuse one panel; defaults to "default".'
|
|
97
|
+
},
|
|
98
|
+
area: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
description:
|
|
101
|
+
'Where the panel docks: "right" (default) puts it in the side area so the editor stays full width; "main" puts it in the document area (split with `mode`).'
|
|
102
|
+
},
|
|
103
|
+
mode: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
description:
|
|
106
|
+
'Only when `area` is "main": placement relative to the active tab, e.g. "split-right" (default), "split-bottom", "tab-after".'
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
execute: async args => {
|
|
112
|
+
const mimeType =
|
|
113
|
+
typeof args['mimeType'] === 'string' ? args['mimeType'] : undefined;
|
|
114
|
+
if (!mimeType || args['data'] === undefined) {
|
|
115
|
+
throw new Error('xtralab:show requires "mimeType" and "data"');
|
|
116
|
+
}
|
|
117
|
+
const data = coerceData(mimeType, args['data']);
|
|
118
|
+
const label =
|
|
119
|
+
typeof args['label'] === 'string'
|
|
120
|
+
? args['label']
|
|
121
|
+
: trans.__('Output');
|
|
122
|
+
const id = typeof args['id'] === 'string' ? args['id'] : 'default';
|
|
123
|
+
const area = args['area'] === 'main' ? 'main' : 'right';
|
|
124
|
+
const mode =
|
|
125
|
+
typeof args['mode'] === 'string' ? args['mode'] : 'split-right';
|
|
126
|
+
|
|
127
|
+
const bundle = { [mimeType]: data };
|
|
128
|
+
const chosen = rendermime.preferredMimeType(bundle, 'any');
|
|
129
|
+
if (!chosen) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`xtralab:show: no renderer for MIME type "${mimeType}"`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const model = rendermime.createModel({ data: bundle, trusted: true });
|
|
136
|
+
const renderer = rendermime.createRenderer(chosen);
|
|
137
|
+
// A scrollable Panel, not a MainAreaWidget: MainAreaWidget does not lay
|
|
138
|
+
// out its content in the side area, and the panel needs to scroll when
|
|
139
|
+
// the content is taller than it (style/showOutput.css).
|
|
140
|
+
const widget = new Panel();
|
|
141
|
+
widget.addClass('jp-xtralab-ShowPanel');
|
|
142
|
+
widget.id = `xtralab-show-${id}`;
|
|
143
|
+
widget.title.label = label;
|
|
144
|
+
widget.title.closable = true;
|
|
145
|
+
widget.addWidget(renderer);
|
|
146
|
+
|
|
147
|
+
// Replace any existing panel with this id so shows refresh in place.
|
|
148
|
+
const existing = panels.get(id);
|
|
149
|
+
if (existing && !existing.isDisposed) {
|
|
150
|
+
existing.dispose();
|
|
151
|
+
}
|
|
152
|
+
panels.set(id, widget);
|
|
153
|
+
widget.disposed.connect(() => {
|
|
154
|
+
if (panels.get(id) === widget) {
|
|
155
|
+
panels.delete(id);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Attach before rendering so renderers that need layout (Vega) size
|
|
160
|
+
// correctly. Default to the side area so the editor keeps full width;
|
|
161
|
+
// only split the document area when `area` is "main".
|
|
162
|
+
if (area === 'main') {
|
|
163
|
+
labShell.add(widget, 'main', {
|
|
164
|
+
mode: mode as DocumentMode,
|
|
165
|
+
activate: true
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
labShell.add(widget, 'right', { rank: 900 });
|
|
169
|
+
}
|
|
170
|
+
// Reveal the panel (the side area does not open from `add` alone).
|
|
171
|
+
labShell.activateById(widget.id);
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await renderer.renderModel(model);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
widget.dispose();
|
|
177
|
+
throw error instanceof Error
|
|
178
|
+
? error
|
|
179
|
+
: new Error(`xtralab:show: failed to render "${mimeType}"`);
|
|
180
|
+
}
|
|
181
|
+
return trans.__('Rendered %1 in "%2"', mimeType, label);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (palette) {
|
|
186
|
+
palette.addItem({ command: SHOW_COMMAND, category: trans.__('Other') });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
type DocumentMode =
|
|
192
|
+
| 'split-top'
|
|
193
|
+
| 'split-left'
|
|
194
|
+
| 'split-right'
|
|
195
|
+
| 'split-bottom'
|
|
196
|
+
| 'tab-before'
|
|
197
|
+
| 'tab-after';
|
|
198
|
+
|
|
199
|
+
export default plugin;
|