xtralab 0.9.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 +40 -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 +15 -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/terminalNotifications/index.d.ts +15 -0
- package/lib/terminalNotifications/index.js +213 -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/terminal-notifications.json +21 -0
- 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 +15 -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/terminalNotifications/index.ts +295 -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,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
|
+
}
|
|
@@ -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;
|