xtralab 0.10.0 → 0.11.1
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 +27 -11
- 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 +18 -1
- package/lib/launcher/agents.js +47 -4
- package/lib/launcher/editorRegistry.js +5 -3
- package/lib/launcher/editors.d.ts +17 -2
- package/lib/launcher/editors.js +31 -5
- package/lib/launcher/index.js +7 -1
- 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 +5 -4
- 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 +53 -3
- package/src/launcher/editorRegistry.ts +8 -4
- package/src/launcher/editors.ts +37 -4
- package/src/launcher/index.ts +7 -1
- 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,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JupyterFrontEnd,
|
|
3
|
+
JupyterFrontEndPlugin
|
|
4
|
+
} from '@jupyterlab/application';
|
|
5
|
+
import { ICommandPalette } from '@jupyterlab/apputils';
|
|
6
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
7
|
+
import { Widget } from '@lumino/widgets';
|
|
8
|
+
|
|
9
|
+
import { IAgentRegistry } from '../launcher/tokens';
|
|
10
|
+
|
|
11
|
+
import { IOmnibox, OMNIBOX_OPEN_COMMAND } from './tokens';
|
|
12
|
+
import { OmniboxWidget } from './widget';
|
|
13
|
+
|
|
14
|
+
const PLUGIN_ID = 'xtralab:omnibox';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The omnibox: a single launcher overlay that fuzzy-searches workspace files
|
|
18
|
+
* (via jupyterlab-quickopen's gitignore-aware endpoint) and JupyterLab
|
|
19
|
+
* commands, and routes a typed prompt to one of the configured agents (running
|
|
20
|
+
* it in a fresh terminal through the launcher's `xtralab:start-agent:<id>`
|
|
21
|
+
* commands). It is opened by the top-bar command bar.
|
|
22
|
+
*
|
|
23
|
+
* The agent rows come from the launcher's `IAgentRegistry`; when the launcher
|
|
24
|
+
* is disabled the omnibox still searches files and commands.
|
|
25
|
+
*/
|
|
26
|
+
const plugin: JupyterFrontEndPlugin<IOmnibox> = {
|
|
27
|
+
id: PLUGIN_ID,
|
|
28
|
+
description:
|
|
29
|
+
'A launcher overlay that searches files and commands and prompts agents.',
|
|
30
|
+
autoStart: true,
|
|
31
|
+
provides: IOmnibox,
|
|
32
|
+
optional: [IAgentRegistry, ICommandPalette, ITranslator],
|
|
33
|
+
activate: (
|
|
34
|
+
app: JupyterFrontEnd,
|
|
35
|
+
agentRegistry: IAgentRegistry | null,
|
|
36
|
+
palette: ICommandPalette | null,
|
|
37
|
+
translator: ITranslator | null
|
|
38
|
+
): IOmnibox => {
|
|
39
|
+
const { commands, docRegistry } = app;
|
|
40
|
+
const trans = (translator ?? nullTranslator).load('jupyterlab');
|
|
41
|
+
const placeholder = trans.__('Search files and commands, or ask an agent…');
|
|
42
|
+
|
|
43
|
+
let current: OmniboxWidget | null = null;
|
|
44
|
+
|
|
45
|
+
const close = (): void => {
|
|
46
|
+
const widget = current;
|
|
47
|
+
current = null;
|
|
48
|
+
widget?.dispose();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const open = (query?: string): void => {
|
|
52
|
+
// Reopen fresh each time so the input resets and the agent snapshot is
|
|
53
|
+
// current; the file list is cached separately (see files.ts).
|
|
54
|
+
close();
|
|
55
|
+
const widget = new OmniboxWidget({
|
|
56
|
+
commands,
|
|
57
|
+
docRegistry,
|
|
58
|
+
agents: agentRegistry ? agentRegistry.agents : [],
|
|
59
|
+
placeholder,
|
|
60
|
+
initialQuery: query ?? '',
|
|
61
|
+
trans,
|
|
62
|
+
onClose: close
|
|
63
|
+
});
|
|
64
|
+
current = widget;
|
|
65
|
+
widget.disposed.connect(() => {
|
|
66
|
+
if (current === widget) {
|
|
67
|
+
current = null;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
Widget.attach(widget, document.body);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
commands.addCommand(OMNIBOX_OPEN_COMMAND, {
|
|
74
|
+
label: trans.__('Search…'),
|
|
75
|
+
caption: trans.__('Search files and commands, or ask an agent'),
|
|
76
|
+
execute: args => {
|
|
77
|
+
// Toggle: a second press (e.g. of the keyboard shortcut) closes it.
|
|
78
|
+
if (current && !current.isDisposed) {
|
|
79
|
+
close();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const query = args['query'];
|
|
83
|
+
open(typeof query === 'string' ? query : undefined);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Open the omnibox with Cmd/Ctrl+K, the common "command center" chord.
|
|
88
|
+
commands.addKeyBinding({
|
|
89
|
+
command: OMNIBOX_OPEN_COMMAND,
|
|
90
|
+
keys: ['Accel K'],
|
|
91
|
+
selector: 'body'
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (palette) {
|
|
95
|
+
palette.addItem({
|
|
96
|
+
command: OMNIBOX_OPEN_COMMAND,
|
|
97
|
+
category: trans.__('Other')
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { open, close };
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export default plugin;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { DocumentRegistry } from '@jupyterlab/docregistry';
|
|
2
|
+
import type { TranslationBundle } from '@jupyterlab/translation';
|
|
3
|
+
import { fileIcon, LabIcon } from '@jupyterlab/ui-components';
|
|
4
|
+
import { StringExt } from '@lumino/algorithm';
|
|
5
|
+
import type { CommandRegistry } from '@lumino/commands';
|
|
6
|
+
|
|
7
|
+
import type { IAgent } from '../launcher/agents';
|
|
8
|
+
import { agentCommandId } from '../launcher/tokens';
|
|
9
|
+
|
|
10
|
+
/** The source a result row came from, used to group rows under a header. */
|
|
11
|
+
export type OmniboxItemKind = 'command' | 'file' | 'agent';
|
|
12
|
+
|
|
13
|
+
/** A single result row, with the action it runs when chosen. */
|
|
14
|
+
export interface IOmniboxItem {
|
|
15
|
+
kind: OmniboxItemKind;
|
|
16
|
+
/** Stable React key. */
|
|
17
|
+
key: string;
|
|
18
|
+
/** Primary text shown on the row. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** Indices of matched characters in `label`, for highlighting. May be empty. */
|
|
21
|
+
matchIndices: readonly number[];
|
|
22
|
+
/** Optional secondary text shown trailing the label. */
|
|
23
|
+
caption?: string;
|
|
24
|
+
/** Optional leading icon. */
|
|
25
|
+
icon?: LabIcon;
|
|
26
|
+
/** Run the row's action. */
|
|
27
|
+
execute: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Results grouped by source; each group is already capped and sorted. */
|
|
31
|
+
export interface IOmniboxSections {
|
|
32
|
+
commands: IOmniboxItem[];
|
|
33
|
+
files: IOmniboxItem[];
|
|
34
|
+
agents: IOmniboxItem[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface IComputeOptions {
|
|
38
|
+
query: string;
|
|
39
|
+
commands: CommandRegistry;
|
|
40
|
+
docRegistry: DocumentRegistry;
|
|
41
|
+
agents: IAgent[];
|
|
42
|
+
files: string[];
|
|
43
|
+
trans: TranslationBundle;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const COMMAND_LIMIT = 7;
|
|
47
|
+
const FILE_LIMIT = 10;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Prefixes that narrow the search to a single source: a leading `>` to
|
|
51
|
+
* commands, a leading `/` to files (relative paths never start with `/`, so it
|
|
52
|
+
* is unambiguous). Without a prefix, every source is searched.
|
|
53
|
+
*/
|
|
54
|
+
const COMMAND_PREFIX = '>';
|
|
55
|
+
const FILE_PREFIX = '/';
|
|
56
|
+
|
|
57
|
+
type Mode = 'all' | 'commands' | 'files';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the grouped result set for a query. A leading `>` searches only
|
|
61
|
+
* commands and a leading `/` only files; otherwise commands and files are
|
|
62
|
+
* fuzzy-matched and every prompt-capable agent is offered an "Ask" row
|
|
63
|
+
* carrying the query as its prompt. An empty term yields no rows (the widget
|
|
64
|
+
* shows a hint instead).
|
|
65
|
+
*/
|
|
66
|
+
export function computeSections(options: IComputeOptions): IOmniboxSections {
|
|
67
|
+
const { query, commands, docRegistry, agents, files, trans } = options;
|
|
68
|
+
const trimmed = query.trim();
|
|
69
|
+
|
|
70
|
+
let mode: Mode = 'all';
|
|
71
|
+
let term = trimmed;
|
|
72
|
+
if (trimmed.startsWith(COMMAND_PREFIX)) {
|
|
73
|
+
mode = 'commands';
|
|
74
|
+
term = trimmed.slice(COMMAND_PREFIX.length).trim();
|
|
75
|
+
} else if (trimmed.startsWith(FILE_PREFIX)) {
|
|
76
|
+
mode = 'files';
|
|
77
|
+
term = trimmed.slice(FILE_PREFIX.length).trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!term) {
|
|
81
|
+
return { commands: [], files: [], agents: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
commands: mode === 'files' ? [] : matchCommands(commands, term),
|
|
86
|
+
files:
|
|
87
|
+
mode === 'commands' ? [] : matchFiles(commands, docRegistry, files, term),
|
|
88
|
+
// Agents only in the unprefixed view; the prompt is the full typed query.
|
|
89
|
+
agents: mode === 'all' ? buildAgentItems(commands, agents, term, trans) : []
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function matchCommands(
|
|
94
|
+
commands: CommandRegistry,
|
|
95
|
+
term: string
|
|
96
|
+
): IOmniboxItem[] {
|
|
97
|
+
const query = term.toLowerCase();
|
|
98
|
+
const scored: Array<{ score: number; item: IOmniboxItem }> = [];
|
|
99
|
+
for (const id of commands.listCommands()) {
|
|
100
|
+
let label = '';
|
|
101
|
+
let visible = true;
|
|
102
|
+
let caption = '';
|
|
103
|
+
try {
|
|
104
|
+
// A command's accessors can throw if they assume a context (e.g. an
|
|
105
|
+
// active notebook) the omnibox doesn't provide; skip those.
|
|
106
|
+
label = commands.label(id);
|
|
107
|
+
visible = commands.isVisible(id);
|
|
108
|
+
caption = commands.caption(id);
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!label || !visible) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const match = StringExt.matchSumOfSquares(label.toLowerCase(), query);
|
|
116
|
+
if (!match) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
scored.push({
|
|
120
|
+
score: match.score,
|
|
121
|
+
item: {
|
|
122
|
+
kind: 'command',
|
|
123
|
+
key: `command:${id}`,
|
|
124
|
+
label,
|
|
125
|
+
matchIndices: match.indices,
|
|
126
|
+
caption: caption || undefined,
|
|
127
|
+
execute: () => {
|
|
128
|
+
void commands.execute(id).catch(reason => {
|
|
129
|
+
console.error(`xtralab omnibox: command "${id}" failed`, reason);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
scored.sort((a, b) => a.score - b.score);
|
|
136
|
+
return scored.slice(0, COMMAND_LIMIT).map(entry => entry.item);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function matchFiles(
|
|
140
|
+
commands: CommandRegistry,
|
|
141
|
+
docRegistry: DocumentRegistry,
|
|
142
|
+
files: string[],
|
|
143
|
+
term: string
|
|
144
|
+
): IOmniboxItem[] {
|
|
145
|
+
const query = term.toLowerCase();
|
|
146
|
+
const scored: Array<{ score: number; path: string; indices: number[] }> = [];
|
|
147
|
+
for (const path of files) {
|
|
148
|
+
const match = StringExt.matchSumOfSquares(path.toLowerCase(), query);
|
|
149
|
+
if (match) {
|
|
150
|
+
scored.push({ score: match.score, path, indices: match.indices });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
scored.sort((a, b) => a.score - b.score);
|
|
154
|
+
return scored.slice(0, FILE_LIMIT).map(({ path, indices }) => ({
|
|
155
|
+
kind: 'file',
|
|
156
|
+
key: `file:${path}`,
|
|
157
|
+
label: path,
|
|
158
|
+
matchIndices: indices,
|
|
159
|
+
icon: fileIconForPath(docRegistry, path),
|
|
160
|
+
execute: () => {
|
|
161
|
+
void commands.execute('docmanager:open', { path }).catch(reason => {
|
|
162
|
+
console.error(`xtralab omnibox: failed to open "${path}"`, reason);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function fileIconForPath(docRegistry: DocumentRegistry, path: string): LabIcon {
|
|
169
|
+
for (const fileType of docRegistry.getFileTypesForPath(path)) {
|
|
170
|
+
if (fileType.icon) {
|
|
171
|
+
return fileType.icon;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return fileIcon;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildAgentItems(
|
|
178
|
+
commands: CommandRegistry,
|
|
179
|
+
agents: IAgent[],
|
|
180
|
+
prompt: string,
|
|
181
|
+
trans: TranslationBundle
|
|
182
|
+
): IOmniboxItem[] {
|
|
183
|
+
const items: IOmniboxItem[] = [];
|
|
184
|
+
for (const agent of agents) {
|
|
185
|
+
// Agents without `promptArgs` can't take an inline prompt (the launcher
|
|
186
|
+
// would drop it), so they don't belong in an "Ask …" row.
|
|
187
|
+
if (agent.promptArgs === undefined) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
items.push({
|
|
191
|
+
kind: 'agent',
|
|
192
|
+
key: `agent:${agent.id}`,
|
|
193
|
+
label: trans.__('Ask %1', agent.label),
|
|
194
|
+
matchIndices: [],
|
|
195
|
+
caption: trans.__('Run prompt in a new terminal'),
|
|
196
|
+
icon: agent.icon,
|
|
197
|
+
execute: () => {
|
|
198
|
+
void commands
|
|
199
|
+
.execute(agentCommandId(agent.id), { prompt })
|
|
200
|
+
.catch(reason => {
|
|
201
|
+
console.error(
|
|
202
|
+
`xtralab omnibox: failed to start agent "${agent.id}"`,
|
|
203
|
+
reason
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return items;
|
|
210
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Token } from '@lumino/coreutils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A launcher overlay that fuzzy-searches across multiple sources — workspace
|
|
5
|
+
* files, JupyterLab commands — and routes a typed prompt to one of the
|
|
6
|
+
* configured agents. Provided by `xtralab:omnibox` and consumed (optionally)
|
|
7
|
+
* by the top-bar command bar, which opens it on click.
|
|
8
|
+
*/
|
|
9
|
+
export interface IOmnibox {
|
|
10
|
+
/** Open the overlay, optionally seeding the input with `query`. */
|
|
11
|
+
open(query?: string): void;
|
|
12
|
+
/** Close the overlay if it is open. */
|
|
13
|
+
close(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* DI token for {@link IOmnibox}. Provided by `xtralab:omnibox`; consumed by
|
|
18
|
+
* `xtralab:command-bar` so the top-bar pill opens the omnibox.
|
|
19
|
+
*/
|
|
20
|
+
export const IOmnibox = new Token<IOmnibox>(
|
|
21
|
+
'xtralab:IOmnibox',
|
|
22
|
+
'Opens the omnibox overlay that searches files and commands and prompts agents.'
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Command id that opens the omnibox (registered by `xtralab:omnibox`). Shared
|
|
27
|
+
* here so the command bar can look up its keybinding and show a shortcut hint.
|
|
28
|
+
*/
|
|
29
|
+
export const OMNIBOX_OPEN_COMMAND = 'xtralab:omnibox:open';
|
|
@@ -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
|
+
}
|