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,44 @@
|
|
|
1
|
+
import { defaultAgentSettings } from './agents';
|
|
2
|
+
import { defaultEditorSettings } from './editors';
|
|
3
|
+
/** Schema id the launcher's `agents`/`editors` settings live under. */
|
|
4
|
+
export const LAUNCHER_PLUGIN_ID = 'xtralab:launcher';
|
|
5
|
+
/**
|
|
6
|
+
* Setting registries that already have the transform, so both callers skip
|
|
7
|
+
* re-registering (the registry rejects a duplicate transformer).
|
|
8
|
+
*/
|
|
9
|
+
const registered = new WeakSet();
|
|
10
|
+
/**
|
|
11
|
+
* Register a `fetch` transform that injects xtralab's built-in agent and
|
|
12
|
+
* editor lists as the schema defaults for the `xtralab:launcher` settings, so
|
|
13
|
+
* the Settings Editor shows the shipped list.
|
|
14
|
+
*
|
|
15
|
+
* The schema declares `jupyter.lab.transform: true`, so the registry defers
|
|
16
|
+
* loading the plugin until a transform is registered: callers must invoke this
|
|
17
|
+
* before their first `settingRegistry.load(LAUNCHER_PLUGIN_ID)`. Idempotent, so
|
|
18
|
+
* the two plugins that load these settings can both call it regardless of
|
|
19
|
+
* activation order.
|
|
20
|
+
*/
|
|
21
|
+
export function registerLauncherSchemaDefaults(settingRegistry) {
|
|
22
|
+
if (registered.has(settingRegistry)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
registered.add(settingRegistry);
|
|
26
|
+
settingRegistry.transform(LAUNCHER_PLUGIN_ID, {
|
|
27
|
+
fetch: plugin => {
|
|
28
|
+
const properties = plugin.schema.properties;
|
|
29
|
+
if (properties) {
|
|
30
|
+
// The settings shapes are plain JSON by construction, but their named
|
|
31
|
+
// interfaces lack an index signature, so cast at this TS→JSON boundary.
|
|
32
|
+
if (properties.agents) {
|
|
33
|
+
properties.agents.default =
|
|
34
|
+
defaultAgentSettings();
|
|
35
|
+
}
|
|
36
|
+
if (properties.editors) {
|
|
37
|
+
properties.editors.default =
|
|
38
|
+
defaultEditorSettings();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return plugin;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace file list, cached briefly so reopening the omnibox in quick
|
|
3
|
+
* succession reuses one scan. A failed fetch (e.g. quickopen's server
|
|
4
|
+
* extension is absent) resolves to an empty list so file search degrades to
|
|
5
|
+
* "no results" instead of surfacing an error.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadWorkspaceFiles(): Promise<string[]>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { URLExt } from '@jupyterlab/coreutils';
|
|
2
|
+
import { ServerConnection } from '@jupyterlab/services';
|
|
3
|
+
/**
|
|
4
|
+
* Fetch and flatten the workspace file list from `jupyterlab-quickopen`'s
|
|
5
|
+
* server endpoint (`GET {base}/jupyterlab-quickopen/api/files`).
|
|
6
|
+
*
|
|
7
|
+
* We reuse quickopen's endpoint rather than walking the contents API so the
|
|
8
|
+
* listing honors the same server-side `.gitignore` filtering quickopen does
|
|
9
|
+
* (xtralab ships `respectGitignore: true`) in a single request. The endpoint
|
|
10
|
+
* only exists when quickopen's server extension is installed and the server
|
|
11
|
+
* reads a local filesystem; callers treat a rejection as "no file results".
|
|
12
|
+
*/
|
|
13
|
+
async function fetchWorkspaceFiles(respectGitignore = true) {
|
|
14
|
+
const settings = ServerConnection.makeSettings();
|
|
15
|
+
const params = new URLSearchParams();
|
|
16
|
+
if (respectGitignore) {
|
|
17
|
+
params.append('respect_gitignore', '1');
|
|
18
|
+
}
|
|
19
|
+
// The handler requires a `path` argument; `""` scans from the root.
|
|
20
|
+
params.append('path', '');
|
|
21
|
+
const url = URLExt.join(settings.baseUrl, 'jupyterlab-quickopen', 'api', 'files') +
|
|
22
|
+
`?${params.toString()}`;
|
|
23
|
+
const response = await ServerConnection.makeRequest(url, { method: 'GET' }, settings);
|
|
24
|
+
if (response.status !== 200) {
|
|
25
|
+
throw new ServerConnection.ResponseError(response);
|
|
26
|
+
}
|
|
27
|
+
const data = (await response.json());
|
|
28
|
+
const paths = [];
|
|
29
|
+
for (const dir in data.contents) {
|
|
30
|
+
for (const name of data.contents[dir]) {
|
|
31
|
+
paths.push(dir ? `${dir}/${name}` : name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return paths;
|
|
35
|
+
}
|
|
36
|
+
const CACHE_TTL_MS = 15000;
|
|
37
|
+
let cache = null;
|
|
38
|
+
/**
|
|
39
|
+
* The workspace file list, cached briefly so reopening the omnibox in quick
|
|
40
|
+
* succession reuses one scan. A failed fetch (e.g. quickopen's server
|
|
41
|
+
* extension is absent) resolves to an empty list so file search degrades to
|
|
42
|
+
* "no results" instead of surfacing an error.
|
|
43
|
+
*/
|
|
44
|
+
export function loadWorkspaceFiles() {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
if (!cache || now - cache.at > CACHE_TTL_MS) {
|
|
47
|
+
cache = { at: now, files: fetchWorkspaceFiles().catch(() => []) };
|
|
48
|
+
}
|
|
49
|
+
return cache.files;
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { JupyterFrontEndPlugin } from '@jupyterlab/application';
|
|
2
|
+
import { IOmnibox } from './tokens';
|
|
3
|
+
/**
|
|
4
|
+
* The omnibox: a single launcher overlay that fuzzy-searches workspace files
|
|
5
|
+
* (via jupyterlab-quickopen's gitignore-aware endpoint) and JupyterLab
|
|
6
|
+
* commands, and routes a typed prompt to one of the configured agents (running
|
|
7
|
+
* it in a fresh terminal through the launcher's `xtralab:start-agent:<id>`
|
|
8
|
+
* commands). It is opened by the top-bar command bar.
|
|
9
|
+
*
|
|
10
|
+
* The agent rows come from the launcher's `IAgentRegistry`; when the launcher
|
|
11
|
+
* is disabled the omnibox still searches files and commands.
|
|
12
|
+
*/
|
|
13
|
+
declare const plugin: JupyterFrontEndPlugin<IOmnibox>;
|
|
14
|
+
export default plugin;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ICommandPalette } from '@jupyterlab/apputils';
|
|
2
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
3
|
+
import { Widget } from '@lumino/widgets';
|
|
4
|
+
import { IAgentRegistry } from '../launcher/tokens';
|
|
5
|
+
import { IOmnibox, OMNIBOX_OPEN_COMMAND } from './tokens';
|
|
6
|
+
import { OmniboxWidget } from './widget';
|
|
7
|
+
const PLUGIN_ID = 'xtralab:omnibox';
|
|
8
|
+
/**
|
|
9
|
+
* The omnibox: a single launcher overlay that fuzzy-searches workspace files
|
|
10
|
+
* (via jupyterlab-quickopen's gitignore-aware endpoint) and JupyterLab
|
|
11
|
+
* commands, and routes a typed prompt to one of the configured agents (running
|
|
12
|
+
* it in a fresh terminal through the launcher's `xtralab:start-agent:<id>`
|
|
13
|
+
* commands). It is opened by the top-bar command bar.
|
|
14
|
+
*
|
|
15
|
+
* The agent rows come from the launcher's `IAgentRegistry`; when the launcher
|
|
16
|
+
* is disabled the omnibox still searches files and commands.
|
|
17
|
+
*/
|
|
18
|
+
const plugin = {
|
|
19
|
+
id: PLUGIN_ID,
|
|
20
|
+
description: 'A launcher overlay that searches files and commands and prompts agents.',
|
|
21
|
+
autoStart: true,
|
|
22
|
+
provides: IOmnibox,
|
|
23
|
+
optional: [IAgentRegistry, ICommandPalette, ITranslator],
|
|
24
|
+
activate: (app, agentRegistry, palette, translator) => {
|
|
25
|
+
const { commands, docRegistry } = app;
|
|
26
|
+
const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
|
|
27
|
+
const placeholder = trans.__('Search files and commands, or ask an agent…');
|
|
28
|
+
let current = null;
|
|
29
|
+
const close = () => {
|
|
30
|
+
const widget = current;
|
|
31
|
+
current = null;
|
|
32
|
+
widget === null || widget === void 0 ? void 0 : widget.dispose();
|
|
33
|
+
};
|
|
34
|
+
const open = (query) => {
|
|
35
|
+
// Reopen fresh each time so the input resets and the agent snapshot is
|
|
36
|
+
// current; the file list is cached separately (see files.ts).
|
|
37
|
+
close();
|
|
38
|
+
const widget = new OmniboxWidget({
|
|
39
|
+
commands,
|
|
40
|
+
docRegistry,
|
|
41
|
+
agents: agentRegistry ? agentRegistry.agents : [],
|
|
42
|
+
placeholder,
|
|
43
|
+
initialQuery: query !== null && query !== void 0 ? query : '',
|
|
44
|
+
trans,
|
|
45
|
+
onClose: close
|
|
46
|
+
});
|
|
47
|
+
current = widget;
|
|
48
|
+
widget.disposed.connect(() => {
|
|
49
|
+
if (current === widget) {
|
|
50
|
+
current = null;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
Widget.attach(widget, document.body);
|
|
54
|
+
};
|
|
55
|
+
commands.addCommand(OMNIBOX_OPEN_COMMAND, {
|
|
56
|
+
label: trans.__('Search…'),
|
|
57
|
+
caption: trans.__('Search files and commands, or ask an agent'),
|
|
58
|
+
execute: args => {
|
|
59
|
+
// Toggle: a second press (e.g. of the keyboard shortcut) closes it.
|
|
60
|
+
if (current && !current.isDisposed) {
|
|
61
|
+
close();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const query = args['query'];
|
|
65
|
+
open(typeof query === 'string' ? query : undefined);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
// Open the omnibox with Cmd/Ctrl+K, the common "command center" chord.
|
|
69
|
+
commands.addKeyBinding({
|
|
70
|
+
command: OMNIBOX_OPEN_COMMAND,
|
|
71
|
+
keys: ['Accel K'],
|
|
72
|
+
selector: 'body'
|
|
73
|
+
});
|
|
74
|
+
if (palette) {
|
|
75
|
+
palette.addItem({
|
|
76
|
+
command: OMNIBOX_OPEN_COMMAND,
|
|
77
|
+
category: trans.__('Other')
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return { open, close };
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
export default plugin;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DocumentRegistry } from '@jupyterlab/docregistry';
|
|
2
|
+
import type { TranslationBundle } from '@jupyterlab/translation';
|
|
3
|
+
import { LabIcon } from '@jupyterlab/ui-components';
|
|
4
|
+
import type { CommandRegistry } from '@lumino/commands';
|
|
5
|
+
import type { IAgent } from '../launcher/agents';
|
|
6
|
+
/** The source a result row came from, used to group rows under a header. */
|
|
7
|
+
export type OmniboxItemKind = 'command' | 'file' | 'agent';
|
|
8
|
+
/** A single result row, with the action it runs when chosen. */
|
|
9
|
+
export interface IOmniboxItem {
|
|
10
|
+
kind: OmniboxItemKind;
|
|
11
|
+
/** Stable React key. */
|
|
12
|
+
key: string;
|
|
13
|
+
/** Primary text shown on the row. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Indices of matched characters in `label`, for highlighting. May be empty. */
|
|
16
|
+
matchIndices: readonly number[];
|
|
17
|
+
/** Optional secondary text shown trailing the label. */
|
|
18
|
+
caption?: string;
|
|
19
|
+
/** Optional leading icon. */
|
|
20
|
+
icon?: LabIcon;
|
|
21
|
+
/** Run the row's action. */
|
|
22
|
+
execute: () => void;
|
|
23
|
+
}
|
|
24
|
+
/** Results grouped by source; each group is already capped and sorted. */
|
|
25
|
+
export interface IOmniboxSections {
|
|
26
|
+
commands: IOmniboxItem[];
|
|
27
|
+
files: IOmniboxItem[];
|
|
28
|
+
agents: IOmniboxItem[];
|
|
29
|
+
}
|
|
30
|
+
export interface IComputeOptions {
|
|
31
|
+
query: string;
|
|
32
|
+
commands: CommandRegistry;
|
|
33
|
+
docRegistry: DocumentRegistry;
|
|
34
|
+
agents: IAgent[];
|
|
35
|
+
files: string[];
|
|
36
|
+
trans: TranslationBundle;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build the grouped result set for a query. A leading `>` searches only
|
|
40
|
+
* commands and a leading `/` only files; otherwise commands and files are
|
|
41
|
+
* fuzzy-matched and every prompt-capable agent is offered an "Ask" row
|
|
42
|
+
* carrying the query as its prompt. An empty term yields no rows (the widget
|
|
43
|
+
* shows a hint instead).
|
|
44
|
+
*/
|
|
45
|
+
export declare function computeSections(options: IComputeOptions): IOmniboxSections;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { fileIcon } from '@jupyterlab/ui-components';
|
|
2
|
+
import { StringExt } from '@lumino/algorithm';
|
|
3
|
+
import { agentCommandId } from '../launcher/tokens';
|
|
4
|
+
const COMMAND_LIMIT = 7;
|
|
5
|
+
const FILE_LIMIT = 10;
|
|
6
|
+
/**
|
|
7
|
+
* Prefixes that narrow the search to a single source: a leading `>` to
|
|
8
|
+
* commands, a leading `/` to files (relative paths never start with `/`, so it
|
|
9
|
+
* is unambiguous). Without a prefix, every source is searched.
|
|
10
|
+
*/
|
|
11
|
+
const COMMAND_PREFIX = '>';
|
|
12
|
+
const FILE_PREFIX = '/';
|
|
13
|
+
/**
|
|
14
|
+
* Build the grouped result set for a query. A leading `>` searches only
|
|
15
|
+
* commands and a leading `/` only files; otherwise commands and files are
|
|
16
|
+
* fuzzy-matched and every prompt-capable agent is offered an "Ask" row
|
|
17
|
+
* carrying the query as its prompt. An empty term yields no rows (the widget
|
|
18
|
+
* shows a hint instead).
|
|
19
|
+
*/
|
|
20
|
+
export function computeSections(options) {
|
|
21
|
+
const { query, commands, docRegistry, agents, files, trans } = options;
|
|
22
|
+
const trimmed = query.trim();
|
|
23
|
+
let mode = 'all';
|
|
24
|
+
let term = trimmed;
|
|
25
|
+
if (trimmed.startsWith(COMMAND_PREFIX)) {
|
|
26
|
+
mode = 'commands';
|
|
27
|
+
term = trimmed.slice(COMMAND_PREFIX.length).trim();
|
|
28
|
+
}
|
|
29
|
+
else if (trimmed.startsWith(FILE_PREFIX)) {
|
|
30
|
+
mode = 'files';
|
|
31
|
+
term = trimmed.slice(FILE_PREFIX.length).trim();
|
|
32
|
+
}
|
|
33
|
+
if (!term) {
|
|
34
|
+
return { commands: [], files: [], agents: [] };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
commands: mode === 'files' ? [] : matchCommands(commands, term),
|
|
38
|
+
files: mode === 'commands' ? [] : matchFiles(commands, docRegistry, files, term),
|
|
39
|
+
// Agents only in the unprefixed view; the prompt is the full typed query.
|
|
40
|
+
agents: mode === 'all' ? buildAgentItems(commands, agents, term, trans) : []
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function matchCommands(commands, term) {
|
|
44
|
+
const query = term.toLowerCase();
|
|
45
|
+
const scored = [];
|
|
46
|
+
for (const id of commands.listCommands()) {
|
|
47
|
+
let label = '';
|
|
48
|
+
let visible = true;
|
|
49
|
+
let caption = '';
|
|
50
|
+
try {
|
|
51
|
+
// A command's accessors can throw if they assume a context (e.g. an
|
|
52
|
+
// active notebook) the omnibox doesn't provide; skip those.
|
|
53
|
+
label = commands.label(id);
|
|
54
|
+
visible = commands.isVisible(id);
|
|
55
|
+
caption = commands.caption(id);
|
|
56
|
+
}
|
|
57
|
+
catch (_a) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!label || !visible) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const match = StringExt.matchSumOfSquares(label.toLowerCase(), query);
|
|
64
|
+
if (!match) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
scored.push({
|
|
68
|
+
score: match.score,
|
|
69
|
+
item: {
|
|
70
|
+
kind: 'command',
|
|
71
|
+
key: `command:${id}`,
|
|
72
|
+
label,
|
|
73
|
+
matchIndices: match.indices,
|
|
74
|
+
caption: caption || undefined,
|
|
75
|
+
execute: () => {
|
|
76
|
+
void commands.execute(id).catch(reason => {
|
|
77
|
+
console.error(`xtralab omnibox: command "${id}" failed`, reason);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
scored.sort((a, b) => a.score - b.score);
|
|
84
|
+
return scored.slice(0, COMMAND_LIMIT).map(entry => entry.item);
|
|
85
|
+
}
|
|
86
|
+
function matchFiles(commands, docRegistry, files, term) {
|
|
87
|
+
const query = term.toLowerCase();
|
|
88
|
+
const scored = [];
|
|
89
|
+
for (const path of files) {
|
|
90
|
+
const match = StringExt.matchSumOfSquares(path.toLowerCase(), query);
|
|
91
|
+
if (match) {
|
|
92
|
+
scored.push({ score: match.score, path, indices: match.indices });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
scored.sort((a, b) => a.score - b.score);
|
|
96
|
+
return scored.slice(0, FILE_LIMIT).map(({ path, indices }) => ({
|
|
97
|
+
kind: 'file',
|
|
98
|
+
key: `file:${path}`,
|
|
99
|
+
label: path,
|
|
100
|
+
matchIndices: indices,
|
|
101
|
+
icon: fileIconForPath(docRegistry, path),
|
|
102
|
+
execute: () => {
|
|
103
|
+
void commands.execute('docmanager:open', { path }).catch(reason => {
|
|
104
|
+
console.error(`xtralab omnibox: failed to open "${path}"`, reason);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
function fileIconForPath(docRegistry, path) {
|
|
110
|
+
for (const fileType of docRegistry.getFileTypesForPath(path)) {
|
|
111
|
+
if (fileType.icon) {
|
|
112
|
+
return fileType.icon;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return fileIcon;
|
|
116
|
+
}
|
|
117
|
+
function buildAgentItems(commands, agents, prompt, trans) {
|
|
118
|
+
const items = [];
|
|
119
|
+
for (const agent of agents) {
|
|
120
|
+
// Agents without `promptArgs` can't take an inline prompt (the launcher
|
|
121
|
+
// would drop it), so they don't belong in an "Ask …" row.
|
|
122
|
+
if (agent.promptArgs === undefined) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
items.push({
|
|
126
|
+
kind: 'agent',
|
|
127
|
+
key: `agent:${agent.id}`,
|
|
128
|
+
label: trans.__('Ask %1', agent.label),
|
|
129
|
+
matchIndices: [],
|
|
130
|
+
caption: trans.__('Run prompt in a new terminal'),
|
|
131
|
+
icon: agent.icon,
|
|
132
|
+
execute: () => {
|
|
133
|
+
void commands
|
|
134
|
+
.execute(agentCommandId(agent.id), { prompt })
|
|
135
|
+
.catch(reason => {
|
|
136
|
+
console.error(`xtralab omnibox: failed to start agent "${agent.id}"`, reason);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return items;
|
|
142
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Token } from '@lumino/coreutils';
|
|
2
|
+
/**
|
|
3
|
+
* A launcher overlay that fuzzy-searches across multiple sources — workspace
|
|
4
|
+
* files, JupyterLab commands — and routes a typed prompt to one of the
|
|
5
|
+
* configured agents. Provided by `xtralab:omnibox` and consumed (optionally)
|
|
6
|
+
* by the top-bar command bar, which opens it on click.
|
|
7
|
+
*/
|
|
8
|
+
export interface IOmnibox {
|
|
9
|
+
/** Open the overlay, optionally seeding the input with `query`. */
|
|
10
|
+
open(query?: string): void;
|
|
11
|
+
/** Close the overlay if it is open. */
|
|
12
|
+
close(): void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* DI token for {@link IOmnibox}. Provided by `xtralab:omnibox`; consumed by
|
|
16
|
+
* `xtralab:command-bar` so the top-bar pill opens the omnibox.
|
|
17
|
+
*/
|
|
18
|
+
export declare const IOmnibox: Token<IOmnibox>;
|
|
19
|
+
/**
|
|
20
|
+
* Command id that opens the omnibox (registered by `xtralab:omnibox`). Shared
|
|
21
|
+
* here so the command bar can look up its keybinding and show a shortcut hint.
|
|
22
|
+
*/
|
|
23
|
+
export declare const OMNIBOX_OPEN_COMMAND = "xtralab:omnibox:open";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Token } from '@lumino/coreutils';
|
|
2
|
+
/**
|
|
3
|
+
* DI token for {@link IOmnibox}. Provided by `xtralab:omnibox`; consumed by
|
|
4
|
+
* `xtralab:command-bar` so the top-bar pill opens the omnibox.
|
|
5
|
+
*/
|
|
6
|
+
export const IOmnibox = new Token('xtralab:IOmnibox', 'Opens the omnibox overlay that searches files and commands and prompts agents.');
|
|
7
|
+
/**
|
|
8
|
+
* Command id that opens the omnibox (registered by `xtralab:omnibox`). Shared
|
|
9
|
+
* here so the command bar can look up its keybinding and show a shortcut hint.
|
|
10
|
+
*/
|
|
11
|
+
export const OMNIBOX_OPEN_COMMAND = 'xtralab:omnibox:open';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DocumentRegistry } from '@jupyterlab/docregistry';
|
|
2
|
+
import type { TranslationBundle } from '@jupyterlab/translation';
|
|
3
|
+
import { ReactWidget } from '@jupyterlab/ui-components';
|
|
4
|
+
import type { CommandRegistry } from '@lumino/commands';
|
|
5
|
+
import type { IAgent } from '../launcher/agents';
|
|
6
|
+
/**
|
|
7
|
+
* The omnibox overlay widget. Hosts {@link OmniboxComponent} in a React root
|
|
8
|
+
* attached to `document.body`. It carries `jp-ThemedContainer` so JupyterLab's
|
|
9
|
+
* theme CSS variables resolve outside the shell, and its own `jp-xtralab-Omnibox`
|
|
10
|
+
* class for the overlay/panel styling (style/omnibox.css).
|
|
11
|
+
*/
|
|
12
|
+
export declare class OmniboxWidget extends ReactWidget {
|
|
13
|
+
constructor(options: OmniboxWidget.IOptions);
|
|
14
|
+
render(): JSX.Element;
|
|
15
|
+
private _options;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A namespace for `OmniboxWidget` statics.
|
|
19
|
+
*/
|
|
20
|
+
export declare namespace OmniboxWidget {
|
|
21
|
+
/** Construction options for {@link OmniboxWidget}. */
|
|
22
|
+
interface IOptions {
|
|
23
|
+
commands: CommandRegistry;
|
|
24
|
+
docRegistry: DocumentRegistry;
|
|
25
|
+
/** Snapshot of the available agents, read once when the overlay opens. */
|
|
26
|
+
agents: IAgent[];
|
|
27
|
+
/** Placeholder text for the input. */
|
|
28
|
+
placeholder: string;
|
|
29
|
+
/** Seed text for the input. */
|
|
30
|
+
initialQuery: string;
|
|
31
|
+
/** Translation bundle for the overlay's own labels. */
|
|
32
|
+
trans: TranslationBundle;
|
|
33
|
+
/** Dismiss the overlay (the plugin disposes the widget). */
|
|
34
|
+
onClose: () => void;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { ReactWidget } from '@jupyterlab/ui-components';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { computeSections } from './model';
|
|
4
|
+
import { loadWorkspaceFiles } from './files';
|
|
5
|
+
/** DOM id of the results listbox, referenced by the input's `aria-controls`. */
|
|
6
|
+
const LIST_ID = 'jp-xtralab-Omnibox-list';
|
|
7
|
+
/** Stable DOM id for the result row at `index`, for `aria-activedescendant`. */
|
|
8
|
+
function optionId(index) {
|
|
9
|
+
return `jp-xtralab-Omnibox-option-${index}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Render `text` with the fuzzy-matched characters at `indices` wrapped in a
|
|
13
|
+
* highlight span, leaving the rest as plain text.
|
|
14
|
+
*/
|
|
15
|
+
function renderHighlight(text, indices) {
|
|
16
|
+
if (indices.length === 0) {
|
|
17
|
+
return text;
|
|
18
|
+
}
|
|
19
|
+
const matched = new Set(indices);
|
|
20
|
+
const nodes = [];
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < text.length) {
|
|
23
|
+
const isMatch = matched.has(i);
|
|
24
|
+
let j = i;
|
|
25
|
+
while (j < text.length && matched.has(j) === isMatch) {
|
|
26
|
+
j++;
|
|
27
|
+
}
|
|
28
|
+
const chunk = text.slice(i, j);
|
|
29
|
+
nodes.push(isMatch ? (React.createElement("span", { className: "jp-xtralab-Omnibox-match", key: i }, chunk)) : (React.createElement(React.Fragment, { key: i }, chunk)));
|
|
30
|
+
i = j;
|
|
31
|
+
}
|
|
32
|
+
return nodes;
|
|
33
|
+
}
|
|
34
|
+
function renderIcon(icon) {
|
|
35
|
+
return icon ? (React.createElement(icon.react, { tag: "span", className: "jp-xtralab-Omnibox-itemIcon" })) : (React.createElement("span", { className: "jp-xtralab-Omnibox-itemIcon" }));
|
|
36
|
+
}
|
|
37
|
+
function OmniboxComponent(props) {
|
|
38
|
+
const { commands, docRegistry, agents, placeholder, initialQuery, trans, onClose } = props;
|
|
39
|
+
const [query, setQuery] = React.useState(initialQuery);
|
|
40
|
+
const [files, setFiles] = React.useState([]);
|
|
41
|
+
const [active, setActive] = React.useState(0);
|
|
42
|
+
const inputRef = React.useRef(null);
|
|
43
|
+
const listRef = React.useRef(null);
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
var _a;
|
|
46
|
+
(_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
|
|
47
|
+
}, []);
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
void loadWorkspaceFiles().then(loaded => {
|
|
51
|
+
if (!cancelled) {
|
|
52
|
+
setFiles(loaded);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
};
|
|
58
|
+
}, []);
|
|
59
|
+
const sections = React.useMemo(() => computeSections({ query, commands, docRegistry, agents, files, trans }), [query, commands, docRegistry, agents, files, trans]);
|
|
60
|
+
const flat = React.useMemo(() => [...sections.commands, ...sections.files, ...sections.agents], [sections]);
|
|
61
|
+
const hasResults = flat.length > 0;
|
|
62
|
+
// Reset the highlighted row whenever the result set changes, and keep the
|
|
63
|
+
// index in range when results shrink.
|
|
64
|
+
React.useEffect(() => {
|
|
65
|
+
setActive(current => (current < flat.length ? current : 0));
|
|
66
|
+
}, [flat]);
|
|
67
|
+
// Keep the highlighted row scrolled into view as it moves.
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
var _a, _b;
|
|
70
|
+
(_b = (_a = listRef.current) === null || _a === void 0 ? void 0 : _a.querySelector('.jp-mod-active')) === null || _b === void 0 ? void 0 : _b.scrollIntoView({ block: 'nearest' });
|
|
71
|
+
}, [active, flat]);
|
|
72
|
+
const dismiss = React.useCallback(() => {
|
|
73
|
+
// Defer so unmounting this React root (via onClose) never happens
|
|
74
|
+
// synchronously inside the event handler that triggered it.
|
|
75
|
+
window.setTimeout(onClose, 0);
|
|
76
|
+
}, [onClose]);
|
|
77
|
+
const run = React.useCallback((item) => {
|
|
78
|
+
window.setTimeout(() => {
|
|
79
|
+
onClose();
|
|
80
|
+
item.execute();
|
|
81
|
+
}, 0);
|
|
82
|
+
}, [onClose]);
|
|
83
|
+
const onKeyDown = (event) => {
|
|
84
|
+
switch (event.key) {
|
|
85
|
+
case 'ArrowDown':
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
setActive(current => Math.min(current + 1, flat.length - 1));
|
|
89
|
+
break;
|
|
90
|
+
case 'ArrowUp':
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
event.stopPropagation();
|
|
93
|
+
setActive(current => Math.max(current - 1, 0));
|
|
94
|
+
break;
|
|
95
|
+
case 'Enter': {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
event.stopPropagation();
|
|
98
|
+
const item = flat[active];
|
|
99
|
+
if (item) {
|
|
100
|
+
run(item);
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case 'Escape':
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
event.stopPropagation();
|
|
107
|
+
dismiss();
|
|
108
|
+
break;
|
|
109
|
+
default:
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const renderSection = (title, items, startIndex) => {
|
|
114
|
+
if (items.length === 0) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return (React.createElement("div", { className: "jp-xtralab-Omnibox-section", role: "group", "aria-label": title, key: title },
|
|
118
|
+
React.createElement("div", { className: "jp-xtralab-Omnibox-sectionHeader", "aria-hidden": "true" }, title),
|
|
119
|
+
items.map((item, offset) => {
|
|
120
|
+
const index = startIndex + offset;
|
|
121
|
+
const isActive = index === active;
|
|
122
|
+
return (React.createElement("div", { key: item.key, id: optionId(index), role: "option", "aria-selected": isActive, className: 'jp-xtralab-Omnibox-item' + (isActive ? ' jp-mod-active' : ''), onMouseEnter: () => setActive(index), onClick: () => run(item) },
|
|
123
|
+
renderIcon(item.icon),
|
|
124
|
+
React.createElement("span", { className: "jp-xtralab-Omnibox-itemLabel" }, renderHighlight(item.label, item.matchIndices)),
|
|
125
|
+
item.caption ? (React.createElement("span", { className: "jp-xtralab-Omnibox-itemCaption" }, item.caption)) : null));
|
|
126
|
+
})));
|
|
127
|
+
};
|
|
128
|
+
const filesStart = sections.commands.length;
|
|
129
|
+
const agentsStart = filesStart + sections.files.length;
|
|
130
|
+
return (React.createElement("div", { className: "jp-xtralab-Omnibox-overlay", onMouseDown: event => {
|
|
131
|
+
if (event.target === event.currentTarget) {
|
|
132
|
+
dismiss();
|
|
133
|
+
}
|
|
134
|
+
} },
|
|
135
|
+
React.createElement("div", { className: "jp-xtralab-Omnibox-panel", role: "dialog", "aria-modal": "true", "aria-label": placeholder, onMouseDown: event => event.stopPropagation() },
|
|
136
|
+
React.createElement("input", { ref: inputRef, className: "jp-xtralab-Omnibox-input", type: "text", spellCheck: false, placeholder: placeholder, "aria-label": placeholder, role: "combobox", "aria-autocomplete": "list", "aria-expanded": hasResults, "aria-controls": LIST_ID, "aria-activedescendant": hasResults ? optionId(active) : undefined, value: query, onChange: event => setQuery(event.target.value), onKeyDown: onKeyDown }),
|
|
137
|
+
React.createElement("div", { className: "jp-xtralab-Omnibox-results", id: LIST_ID, role: hasResults ? 'listbox' : undefined, ref: listRef }, hasResults ? (React.createElement(React.Fragment, null,
|
|
138
|
+
renderSection(trans.__('Commands'), sections.commands, 0),
|
|
139
|
+
renderSection(trans.__('Files'), sections.files, filesStart),
|
|
140
|
+
renderSection(trans.__('Ask an agent'), sections.agents, agentsStart))) : (React.createElement("div", { className: "jp-xtralab-Omnibox-empty" }, query.trim()
|
|
141
|
+
? trans.__('No matches.')
|
|
142
|
+
: trans.__('Search files and commands, or type a prompt and pick an agent. Prefix with > for commands or / for files.')))))));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The omnibox overlay widget. Hosts {@link OmniboxComponent} in a React root
|
|
146
|
+
* attached to `document.body`. It carries `jp-ThemedContainer` so JupyterLab's
|
|
147
|
+
* theme CSS variables resolve outside the shell, and its own `jp-xtralab-Omnibox`
|
|
148
|
+
* class for the overlay/panel styling (style/omnibox.css).
|
|
149
|
+
*/
|
|
150
|
+
export class OmniboxWidget extends ReactWidget {
|
|
151
|
+
constructor(options) {
|
|
152
|
+
super();
|
|
153
|
+
this._options = options;
|
|
154
|
+
this.id = 'xtralab-omnibox';
|
|
155
|
+
this.addClass('jp-xtralab-Omnibox');
|
|
156
|
+
this.addClass('jp-ThemedContainer');
|
|
157
|
+
}
|
|
158
|
+
render() {
|
|
159
|
+
return React.createElement(OmniboxComponent, { ...this._options });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { JupyterFrontEndPlugin } from '@jupyterlab/application';
|
|
2
|
+
/**
|
|
3
|
+
* Bind Accel+Shift+F (Cmd+Shift+F on macOS, Ctrl+Shift+F elsewhere) to the
|
|
4
|
+
* project-wide Search and Replace panel, matching the equivalent shortcut in
|
|
5
|
+
* editors like VS Code.
|
|
6
|
+
*
|
|
7
|
+
* Two pieces are needed because that chord is already taken: JupyterLab core
|
|
8
|
+
* binds Accel+Shift+F to `filebrowser:toggle-main`.
|
|
9
|
+
*
|
|
10
|
+
* 1. The chord is freed declaratively. `SettingRegistry.reconcileShortcuts`
|
|
11
|
+
* keys collisions and disables on (keys, selector) — not on command — so the
|
|
12
|
+
* `@jupyterlab/shortcuts-extension:shortcuts` override in
|
|
13
|
+
* `default_setting_overrides.d/00-xtralab.json` disables the file-browser
|
|
14
|
+
* binding. The file browser stays reachable from its sidebar tab and the
|
|
15
|
+
* View menu; only the keyboard shortcut moves.
|
|
16
|
+
*
|
|
17
|
+
* 2. The new binding is added imperatively below rather than as another shipped
|
|
18
|
+
* default. A `disabled` default and a re-binding default land on the same
|
|
19
|
+
* (keys, selector) slot, and the disable suppresses every default on that
|
|
20
|
+
* slot — including the re-binding. `commands.addKeyBinding` runs outside the
|
|
21
|
+
* reconcile pass, so it is unaffected and wins cleanly once the file-browser
|
|
22
|
+
* binding is gone.
|
|
23
|
+
*/
|
|
24
|
+
declare const plugin: JupyterFrontEndPlugin<void>;
|
|
25
|
+
export default plugin;
|