xtralab 0.1.1 → 0.2.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 +86 -0
- package/lib/fileBrowser/fileBrowser.js +71 -1
- package/lib/fileBrowser/gitignore.d.ts +29 -0
- package/lib/fileBrowser/gitignore.js +74 -0
- package/lib/fileBrowser/index.js +1 -1
- package/lib/git/api.d.ts +11 -0
- package/lib/git/api.js +19 -0
- package/lib/git/commands.d.ts +3 -1
- package/lib/git/commands.js +10 -4
- package/lib/git/diffWidget.d.ts +11 -0
- package/lib/git/diffWidget.js +90 -9
- package/lib/git/index.js +23 -7
- package/lib/git/panelComponent.d.ts +3 -1
- package/lib/git/panelComponent.js +6 -3
- package/lib/index.js +9 -1
- package/lib/launcher/agents.d.ts +78 -0
- package/lib/launcher/agents.js +168 -0
- package/lib/launcher/availability.d.ts +12 -0
- package/lib/launcher/availability.js +53 -0
- package/lib/launcher/commands.d.ts +22 -0
- package/lib/launcher/commands.js +89 -0
- package/lib/launcher/dashboard.d.ts +46 -0
- package/lib/launcher/dashboard.js +164 -0
- package/lib/launcher/icons.d.ts +33 -0
- package/lib/launcher/icons.js +113 -0
- package/lib/launcher/index.d.ts +23 -0
- package/lib/launcher/index.js +191 -0
- package/lib/launcher/invocation.d.ts +29 -0
- package/lib/launcher/invocation.js +42 -0
- package/lib/menus/index.d.ts +14 -0
- package/lib/menus/index.js +102 -0
- package/lib/sidebar/index.d.ts +18 -0
- package/lib/sidebar/index.js +155 -0
- package/lib/statusBar/index.d.ts +14 -0
- package/lib/statusBar/index.js +89 -0
- package/lib/statusBar/widget.d.ts +121 -0
- package/lib/statusBar/widget.js +237 -0
- package/package.json +10 -2
- package/schema/launcher.json +70 -0
- package/schema/sidebar.json +57 -0
- package/src/fileBrowser/fileBrowser.tsx +75 -1
- package/src/fileBrowser/gitignore.ts +86 -0
- package/src/fileBrowser/index.ts +1 -1
- package/src/git/api.ts +20 -0
- package/src/git/commands.ts +19 -5
- package/src/git/diffWidget.tsx +109 -13
- package/src/git/index.ts +32 -6
- package/src/git/panelComponent.tsx +17 -5
- package/src/index.ts +9 -1
- package/src/launcher/agents.ts +248 -0
- package/src/launcher/availability.ts +73 -0
- package/src/launcher/commands.ts +103 -0
- package/src/launcher/dashboard.tsx +356 -0
- package/src/launcher/icons.ts +123 -0
- package/src/launcher/index.ts +228 -0
- package/src/launcher/invocation.ts +45 -0
- package/src/menus/index.ts +124 -0
- package/src/sidebar/index.ts +195 -0
- package/src/statusBar/index.ts +115 -0
- package/src/statusBar/widget.tsx +318 -0
- package/style/git.css +4 -8
- package/style/index.css +2 -0
- package/style/index.js +2 -0
- package/style/launcher.css +323 -0
- package/style/statusBar.css +95 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ILabShell,
|
|
3
|
+
JupyterFrontEnd,
|
|
4
|
+
JupyterFrontEndPlugin
|
|
5
|
+
} from '@jupyterlab/application';
|
|
6
|
+
import { ICommandPalette, MainAreaWidget } from '@jupyterlab/apputils';
|
|
7
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
8
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
9
|
+
import { launcherIcon } from '@jupyterlab/ui-components';
|
|
10
|
+
import { find } from '@lumino/algorithm';
|
|
11
|
+
import type { ReadonlyPartialJSONObject } from '@lumino/coreutils';
|
|
12
|
+
import type { IDisposable } from '@lumino/disposable';
|
|
13
|
+
import type { Widget } from '@lumino/widgets';
|
|
14
|
+
|
|
15
|
+
import { mergeAgents, type IAgent, type IAgentSettings } from './agents';
|
|
16
|
+
import { fetchAvailableCommands } from './availability';
|
|
17
|
+
import { CREATE_LAUNCHER_COMMAND, registerAgentCommands } from './commands';
|
|
18
|
+
import { LauncherDashboard } from './dashboard';
|
|
19
|
+
|
|
20
|
+
const PLUGIN_ID = 'xtralab:launcher';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The xtralab launcher plugin. Replaces the stock JupyterLab launcher
|
|
24
|
+
* (which is disabled via `package.json`'s `jupyterlab.disabledExtensions`)
|
|
25
|
+
* with an agent-focused dashboard: an optional initial prompt, a row of
|
|
26
|
+
* agent buttons (Claude, Codex, Gemini, …), and a collapsible list of
|
|
27
|
+
* changed files (clickable into the diff viewer) below them. Clicking an
|
|
28
|
+
* agent opens a fresh terminal and pipes the agent's command into it; if
|
|
29
|
+
* the prompt textarea is non-empty, the prompt is shell-quoted and
|
|
30
|
+
* spliced into the launch line per the agent's `promptArgs` recipe.
|
|
31
|
+
*
|
|
32
|
+
* The agent list is the merge of xtralab's defaults with the user's
|
|
33
|
+
* `xtralab:launcher` settings, then filtered by a server-side `which`
|
|
34
|
+
* check so users only see agents that are actually installed. Agents with
|
|
35
|
+
* `requireAvailable: false` (e.g. shell aliases) skip the filter.
|
|
36
|
+
*
|
|
37
|
+
* The plugin deliberately does NOT provide the `ILauncher` token: other
|
|
38
|
+
* extensions register notebook/console/terminal cards on it as a side
|
|
39
|
+
* effect, which would defeat the point of the agent-only launcher. If an
|
|
40
|
+
* extension needs to surface itself, we'll add it here explicitly.
|
|
41
|
+
*/
|
|
42
|
+
const plugin: JupyterFrontEndPlugin<void> = {
|
|
43
|
+
id: PLUGIN_ID,
|
|
44
|
+
description:
|
|
45
|
+
'An agent-focused launcher that replaces the default JupyterLab launcher.',
|
|
46
|
+
autoStart: true,
|
|
47
|
+
optional: [ILabShell, ICommandPalette, ISettingRegistry, ITranslator],
|
|
48
|
+
activate: async (
|
|
49
|
+
app: JupyterFrontEnd,
|
|
50
|
+
labShell: ILabShell | null,
|
|
51
|
+
palette: ICommandPalette | null,
|
|
52
|
+
settingRegistry: ISettingRegistry | null,
|
|
53
|
+
translator: ITranslator | null
|
|
54
|
+
): Promise<void> => {
|
|
55
|
+
const { commands, shell } = app;
|
|
56
|
+
const trans = (translator ?? nullTranslator).load('jupyterlab');
|
|
57
|
+
|
|
58
|
+
// The currently active agent list. Updated whenever settings change so
|
|
59
|
+
// every freshly-created launcher widget renders the latest list.
|
|
60
|
+
let currentAgents: IAgent[] = [];
|
|
61
|
+
|
|
62
|
+
// Track command registrations so a settings change can wipe them
|
|
63
|
+
// before re-registering — without this the command palette would
|
|
64
|
+
// accumulate stale entries when the agent list shrinks.
|
|
65
|
+
let registered: IDisposable | null = null;
|
|
66
|
+
|
|
67
|
+
const applyAgents = async (overrides: IAgentSettings[]): Promise<void> => {
|
|
68
|
+
const agents = mergeAgents(overrides);
|
|
69
|
+
const filtered = await filterByAvailability(agents);
|
|
70
|
+
|
|
71
|
+
registered?.dispose();
|
|
72
|
+
registered = registerAgentCommands(app, filtered);
|
|
73
|
+
currentAgents = filtered;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const readOverrides = (
|
|
77
|
+
settings: ISettingRegistry.ISettings
|
|
78
|
+
): IAgentSettings[] => {
|
|
79
|
+
const raw = settings.composite.agents;
|
|
80
|
+
return Array.isArray(raw) ? (raw as IAgentSettings[]) : [];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (settingRegistry) {
|
|
84
|
+
try {
|
|
85
|
+
const settings = await settingRegistry.load(PLUGIN_ID);
|
|
86
|
+
await applyAgents(readOverrides(settings));
|
|
87
|
+
settings.changed.connect(async () => {
|
|
88
|
+
try {
|
|
89
|
+
await applyAgents(readOverrides(settings));
|
|
90
|
+
} catch (reason) {
|
|
91
|
+
console.error(
|
|
92
|
+
'xtralab: failed to reapply launcher settings',
|
|
93
|
+
reason
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
} catch (reason) {
|
|
98
|
+
console.error('xtralab: failed to load launcher settings', reason);
|
|
99
|
+
// Settings load failed — fall back to defaults so the launcher
|
|
100
|
+
// still has cards instead of going silent.
|
|
101
|
+
await applyAgents([]);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
await applyAgents([]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
commands.addCommand(CREATE_LAUNCHER_COMMAND, {
|
|
108
|
+
label: trans.__('New Launcher'),
|
|
109
|
+
execute: (args: ReadonlyPartialJSONObject) => {
|
|
110
|
+
const id = Private.nextId();
|
|
111
|
+
const onAgentLaunch = (item: Widget): void => {
|
|
112
|
+
// When an agent command returns a Widget that ends up in the main
|
|
113
|
+
// area, slot it where this launcher used to sit so opening an
|
|
114
|
+
// agent feels like the launcher transformed into the terminal.
|
|
115
|
+
// Disposing the inner ReactWidget cascades to the MainAreaWidget
|
|
116
|
+
// host via its `content.disposed` connection.
|
|
117
|
+
if (find(shell.widgets('main'), w => w === item)) {
|
|
118
|
+
shell.add(item, 'main', { ref: id });
|
|
119
|
+
launcher.dispose();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const launcher = new LauncherDashboard({
|
|
123
|
+
commands,
|
|
124
|
+
agents: currentAgents,
|
|
125
|
+
onAgentLaunch,
|
|
126
|
+
// Empty repoPath/cwd matches the JupyterLab convention used by
|
|
127
|
+
// the git panel and the stock launcher: let the server resolve
|
|
128
|
+
// the working tree from its root directory.
|
|
129
|
+
repoPath: '',
|
|
130
|
+
cwd: ''
|
|
131
|
+
});
|
|
132
|
+
launcher.title.icon = launcherIcon;
|
|
133
|
+
launcher.title.label = trans.__('Launcher');
|
|
134
|
+
|
|
135
|
+
const main = new MainAreaWidget({ content: launcher });
|
|
136
|
+
// Hide the close button when the launcher is the only thing in the
|
|
137
|
+
// main area: closing it would leave the user staring at an empty
|
|
138
|
+
// shell with no way back.
|
|
139
|
+
main.title.closable = !!Array.from(shell.widgets('main')).length;
|
|
140
|
+
main.id = id;
|
|
141
|
+
|
|
142
|
+
shell.add(main, 'main', {
|
|
143
|
+
activate: args['activate'] as boolean,
|
|
144
|
+
ref: args['ref'] as string
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (labShell) {
|
|
148
|
+
labShell.layoutModified.connect(() => {
|
|
149
|
+
main.title.closable =
|
|
150
|
+
Array.from(labShell.widgets('main')).length > 1;
|
|
151
|
+
}, main);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return main;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (palette) {
|
|
159
|
+
palette.addItem({
|
|
160
|
+
command: CREATE_LAUNCHER_COMMAND,
|
|
161
|
+
category: trans.__('Launcher')
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (labShell) {
|
|
166
|
+
void app.restored.then(() => {
|
|
167
|
+
const maybeCreate = (): void => {
|
|
168
|
+
if (labShell.isEmpty('main')) {
|
|
169
|
+
void commands.execute(CREATE_LAUNCHER_COMMAND);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
labShell.layoutModified.connect(() => {
|
|
173
|
+
maybeCreate();
|
|
174
|
+
});
|
|
175
|
+
// Layout has settled by the time `app.restored` resolves; if it's
|
|
176
|
+
// empty (fresh start, no restored widgets) the connect above won't
|
|
177
|
+
// fire on its own — kick it off here.
|
|
178
|
+
maybeCreate();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
labShell.addButtonEnabled = true;
|
|
182
|
+
labShell.addRequested.connect((sender, arg) => {
|
|
183
|
+
const ref =
|
|
184
|
+
arg.currentTitle?.owner.id ||
|
|
185
|
+
arg.titles[arg.titles.length - 1].owner.id;
|
|
186
|
+
return commands.execute(CREATE_LAUNCHER_COMMAND, { ref });
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Drop agents whose command isn't on `$PATH`, except for entries that
|
|
194
|
+
* explicitly opt out of the check via `requireAvailable: false` (typical
|
|
195
|
+
* use case: shell aliases the user wants surfaced regardless).
|
|
196
|
+
*
|
|
197
|
+
* If the availability endpoint can't be reached we fail open and return
|
|
198
|
+
* the input unchanged — better to show an unreachable agent than to hide
|
|
199
|
+
* the entire launcher because the server extension didn't load.
|
|
200
|
+
*/
|
|
201
|
+
async function filterByAvailability(agents: IAgent[]): Promise<IAgent[]> {
|
|
202
|
+
const commands = agents
|
|
203
|
+
.filter(agent => agent.requireAvailable)
|
|
204
|
+
.map(agent => agent.command);
|
|
205
|
+
if (commands.length === 0) {
|
|
206
|
+
return agents;
|
|
207
|
+
}
|
|
208
|
+
const available = await fetchAvailableCommands(commands);
|
|
209
|
+
if (!available) {
|
|
210
|
+
return agents;
|
|
211
|
+
}
|
|
212
|
+
return agents.filter(
|
|
213
|
+
agent => !agent.requireAvailable || available.has(agent.command)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
namespace Private {
|
|
218
|
+
let counter = 0;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Returns the next unique launcher widget id.
|
|
222
|
+
*/
|
|
223
|
+
export function nextId(): string {
|
|
224
|
+
return `launcher-${counter++}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export default plugin;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { IAgent } from './agents';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Quote a string so it can be safely passed as a single argument on a
|
|
5
|
+
* bash/zsh command line that is *typed into an interactive terminal* —
|
|
6
|
+
* not the usual file-parsing context.
|
|
7
|
+
*
|
|
8
|
+
* The distinction matters: an actual newline byte (0x0A) sent into a PTY
|
|
9
|
+
* is interpreted by the line discipline as Enter regardless of whether
|
|
10
|
+
* it sits inside `'...'` quotes, so the shell never sees the closing
|
|
11
|
+
* quote. We therefore use ANSI-C `$'...'` quoting and emit `\n` (and
|
|
12
|
+
* `\r`) as their two-character escape sequences; the shell expands them
|
|
13
|
+
* to real newlines after the line is submitted, when readline is no
|
|
14
|
+
* longer involved. Backslashes and single quotes are escaped first so
|
|
15
|
+
* the source text round-trips unchanged.
|
|
16
|
+
*/
|
|
17
|
+
export function shellQuote(value: string): string {
|
|
18
|
+
const escaped = value
|
|
19
|
+
.replace(/\\/g, '\\\\')
|
|
20
|
+
.replace(/'/g, "\\'")
|
|
21
|
+
.replace(/\n/g, '\\n')
|
|
22
|
+
.replace(/\r/g, '\\r');
|
|
23
|
+
return `$'${escaped}'`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compose the literal command line typed into a fresh terminal for `agent`.
|
|
28
|
+
* When `prompt` is empty (or whitespace-only), this is just `agent.command`.
|
|
29
|
+
* When non-empty, the prompt is shell-quoted and spliced in using the
|
|
30
|
+
* agent's `promptArgs` recipe — see `IAgent.promptArgs` for the encoding.
|
|
31
|
+
*
|
|
32
|
+
* Returns `agent.command` unchanged when the agent declares no prompt
|
|
33
|
+
* support (`promptArgs === undefined`), so a stray prompt typed into the
|
|
34
|
+
* launcher does not silently mutate the launch line for that agent. The
|
|
35
|
+
* launcher UI separately prevents the click in that case so the dropped
|
|
36
|
+
* prompt is never surprising to the user.
|
|
37
|
+
*/
|
|
38
|
+
export function buildAgentInvocation(agent: IAgent, prompt: string): string {
|
|
39
|
+
const trimmed = prompt.trim();
|
|
40
|
+
if (trimmed.length === 0 || agent.promptArgs === undefined) {
|
|
41
|
+
return agent.command;
|
|
42
|
+
}
|
|
43
|
+
const tokens = [agent.command, ...agent.promptArgs, shellQuote(trimmed)];
|
|
44
|
+
return tokens.filter(token => token.length > 0).join(' ');
|
|
45
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ILabShell,
|
|
3
|
+
JupyterFrontEnd,
|
|
4
|
+
JupyterFrontEndPlugin
|
|
5
|
+
} from '@jupyterlab/application';
|
|
6
|
+
import { IMainMenu } from '@jupyterlab/mainmenu';
|
|
7
|
+
import type { IRankedMenu } from '@jupyterlab/ui-components';
|
|
8
|
+
import type { Menu, MenuBar, Widget } from '@lumino/widgets';
|
|
9
|
+
|
|
10
|
+
const PLUGIN_ID = 'xtralab:menus';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hide the "Run" and "Kernel" top-level menus while no kernel-using widget
|
|
14
|
+
* is open in the main area. The agent-first launcher does not use kernels,
|
|
15
|
+
* so showing those menus on a fresh workspace surfaces commands the user
|
|
16
|
+
* cannot meaningfully invoke; remove them until a notebook or console is
|
|
17
|
+
* opened, then restore them at their original ranks.
|
|
18
|
+
*
|
|
19
|
+
* Detection is duck-typed on `sessionContext`: NotebookPanel and ConsolePanel
|
|
20
|
+
* both expose it, which avoids pulling in `@jupyterlab/notebook` and
|
|
21
|
+
* `@jupyterlab/console` just to identify their widgets.
|
|
22
|
+
*/
|
|
23
|
+
const plugin: JupyterFrontEndPlugin<void> = {
|
|
24
|
+
id: PLUGIN_ID,
|
|
25
|
+
description:
|
|
26
|
+
'Toggle the Run and Kernel main menus based on whether any kernel-using widget is open.',
|
|
27
|
+
autoStart: true,
|
|
28
|
+
requires: [IMainMenu, ILabShell],
|
|
29
|
+
activate: (
|
|
30
|
+
app: JupyterFrontEnd,
|
|
31
|
+
mainMenu: IMainMenu,
|
|
32
|
+
labShell: ILabShell
|
|
33
|
+
): void => {
|
|
34
|
+
// The interface types (IRunMenu, IKernelMenu) extend IRankedMenu, but
|
|
35
|
+
// the concrete instances are RankedMenu/Menu — cast so we can hand them
|
|
36
|
+
// to MenuBar.removeMenu and IMainMenu.addMenu, which expect a `Menu`.
|
|
37
|
+
const menuBar = mainMenu as unknown as MenuBar;
|
|
38
|
+
const runMenu = mainMenu.runMenu as unknown as Menu;
|
|
39
|
+
const kernelMenu = mainMenu.kernelMenu as unknown as Menu;
|
|
40
|
+
|
|
41
|
+
// Capture the schema-assigned ranks so the menus reappear in their
|
|
42
|
+
// original slots between View (rank 3) and Tabs (rank 500). Falling
|
|
43
|
+
// back to the hard-coded defaults from `MainMenu` keeps things sane
|
|
44
|
+
// if the schema ever omits the rank.
|
|
45
|
+
const runRank = mainMenu.runMenu.rank ?? 4;
|
|
46
|
+
const kernelRank = mainMenu.kernelMenu.rank ?? 5;
|
|
47
|
+
|
|
48
|
+
const isPresent = (menu: Menu): boolean =>
|
|
49
|
+
Array.from(menuBar.menus).indexOf(menu) > -1;
|
|
50
|
+
|
|
51
|
+
const update = (): void => {
|
|
52
|
+
const show = hasKernelWidget(labShell);
|
|
53
|
+
if (show) {
|
|
54
|
+
// Don't go through `mainMenu.addMenu`: it picks the insertion index
|
|
55
|
+
// from a private `_items` array that `removeMenu` doesn't update,
|
|
56
|
+
// so re-adding lands the menu at the wrong position. Insert
|
|
57
|
+
// directly on the MenuBar by walking current menus and finding
|
|
58
|
+
// the first sibling with a higher rank.
|
|
59
|
+
if (!isPresent(runMenu)) {
|
|
60
|
+
insertByRank(menuBar, runMenu, runRank);
|
|
61
|
+
}
|
|
62
|
+
if (!isPresent(kernelMenu)) {
|
|
63
|
+
insertByRank(menuBar, kernelMenu, kernelRank);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
if (isPresent(runMenu)) {
|
|
67
|
+
menuBar.removeMenu(runMenu);
|
|
68
|
+
}
|
|
69
|
+
if (isPresent(kernelMenu)) {
|
|
70
|
+
menuBar.removeMenu(kernelMenu);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
void app.restored.then(() => {
|
|
76
|
+
update();
|
|
77
|
+
labShell.layoutModified.connect(update);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True iff any widget in the main area exposes a `sessionContext` — the
|
|
84
|
+
* shared marker for NotebookPanel, ConsolePanel, and kernel-attached file
|
|
85
|
+
* editors. We don't import their concrete types because that would drag
|
|
86
|
+
* `@jupyterlab/notebook` and `@jupyterlab/console` into xtralab's bundle
|
|
87
|
+
* just to do an `instanceof` check.
|
|
88
|
+
*/
|
|
89
|
+
function hasKernelWidget(labShell: ILabShell): boolean {
|
|
90
|
+
for (const widget of labShell.widgets('main')) {
|
|
91
|
+
if (hasSessionContext(widget)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasSessionContext(widget: Widget): boolean {
|
|
99
|
+
const candidate = widget as unknown as { sessionContext?: unknown };
|
|
100
|
+
return (
|
|
101
|
+
candidate.sessionContext !== null && candidate.sessionContext !== undefined
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Insert `menu` into `menuBar` at the position dictated by `rank`, using
|
|
107
|
+
* the existing menus' ranks to find the slot. Menus without a numeric
|
|
108
|
+
* rank are treated as +Infinity so they sink to the end (matching the
|
|
109
|
+
* convention in `MenuFactory.createMenus`).
|
|
110
|
+
*/
|
|
111
|
+
function insertByRank(menuBar: MenuBar, menu: Menu, rank: number): void {
|
|
112
|
+
const menus = menuBar.menus;
|
|
113
|
+
for (let i = 0; i < menus.length; i++) {
|
|
114
|
+
const r = (menus[i] as unknown as IRankedMenu).rank;
|
|
115
|
+
const otherRank = typeof r === 'number' ? r : Number.POSITIVE_INFINITY;
|
|
116
|
+
if (otherRank > rank) {
|
|
117
|
+
menuBar.insertMenu(i, menu);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
menuBar.addMenu(menu);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default plugin;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ILabShell,
|
|
3
|
+
JupyterFrontEnd,
|
|
4
|
+
JupyterFrontEndPlugin
|
|
5
|
+
} from '@jupyterlab/application';
|
|
6
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
7
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
8
|
+
import type { Widget } from '@lumino/widgets';
|
|
9
|
+
|
|
10
|
+
const PLUGIN_ID = 'xtralab:sidebar';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Each entry pairs an upstream sidebar widget id with the rank it was
|
|
14
|
+
* originally added at. The ranks come from the upstream extensions —
|
|
15
|
+
* `filebrowser-extension` adds the default browser at rank 100 and
|
|
16
|
+
* `running-extension` adds the running panel at rank 200 — and we reuse
|
|
17
|
+
* them when re-adding so the tab lands back in its usual position.
|
|
18
|
+
*/
|
|
19
|
+
interface ITarget {
|
|
20
|
+
id: string;
|
|
21
|
+
rank: number;
|
|
22
|
+
settingKey: 'showDefaultFileBrowser' | 'showRunningSessions';
|
|
23
|
+
command: string;
|
|
24
|
+
label: (trans: ReturnType<ITranslator['load']>) => string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TARGETS: ITarget[] = [
|
|
28
|
+
{
|
|
29
|
+
id: 'filebrowser',
|
|
30
|
+
rank: 100,
|
|
31
|
+
settingKey: 'showDefaultFileBrowser',
|
|
32
|
+
command: 'xtralab:toggle-default-filebrowser',
|
|
33
|
+
label: trans => trans.__('Default File Browser')
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'jp-running-sessions',
|
|
37
|
+
rank: 200,
|
|
38
|
+
settingKey: 'showRunningSessions',
|
|
39
|
+
command: 'xtralab:toggle-running-sessions',
|
|
40
|
+
label: trans => trans.__('Running Terminals and Kernels')
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Lets the user toggle individual left-sidebar tabs from
|
|
46
|
+
* View > Appearance > Left Sidebar. Removing a widget from a `LabShell`
|
|
47
|
+
* side area is done by setting `widget.parent = null`: the StackedPanel
|
|
48
|
+
* emits `widgetRemoved`, which the SideBarHandler responds to by removing
|
|
49
|
+
* the matching tab. The widget instance is preserved, so we can re-add it
|
|
50
|
+
* later via `labShell.add` with its original rank.
|
|
51
|
+
*
|
|
52
|
+
* The menu placement is contributed declaratively in
|
|
53
|
+
* `schema/sidebar.json`, so this plugin only registers commands and
|
|
54
|
+
* applies state — it does not touch `IMainMenu` directly.
|
|
55
|
+
*
|
|
56
|
+
* The preference is persisted to `xtralab:sidebar` settings so the choice
|
|
57
|
+
* survives reloads.
|
|
58
|
+
*/
|
|
59
|
+
const plugin: JupyterFrontEndPlugin<void> = {
|
|
60
|
+
id: PLUGIN_ID,
|
|
61
|
+
description:
|
|
62
|
+
'Toggle visibility of selected sidebar tabs (default file browser, running sessions) from the View menu.',
|
|
63
|
+
autoStart: true,
|
|
64
|
+
requires: [ILabShell],
|
|
65
|
+
optional: [ISettingRegistry, ITranslator],
|
|
66
|
+
activate: async (
|
|
67
|
+
app: JupyterFrontEnd,
|
|
68
|
+
labShell: ILabShell,
|
|
69
|
+
settingRegistry: ISettingRegistry | null,
|
|
70
|
+
translator: ITranslator | null
|
|
71
|
+
): Promise<void> => {
|
|
72
|
+
const trans = (translator ?? nullTranslator).load('jupyterlab');
|
|
73
|
+
const { commands } = app;
|
|
74
|
+
|
|
75
|
+
// Cache widget instances so we can re-add them after `widget.parent =
|
|
76
|
+
// null` has detached them from the sidebar. The widget is no longer
|
|
77
|
+
// reachable through `labShell.widgets('left')` once removed, so a
|
|
78
|
+
// missing cache entry would leave us unable to bring the tab back.
|
|
79
|
+
const widgetCache = new Map<string, Widget>();
|
|
80
|
+
|
|
81
|
+
const findInSidebar = (id: string): Widget | null => {
|
|
82
|
+
for (const w of labShell.widgets('left')) {
|
|
83
|
+
if (w.id === id) {
|
|
84
|
+
return w;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const captureWidget = (id: string): Widget | null => {
|
|
91
|
+
const cached = widgetCache.get(id);
|
|
92
|
+
if (cached && !cached.isDisposed) {
|
|
93
|
+
return cached;
|
|
94
|
+
}
|
|
95
|
+
const found = findInSidebar(id);
|
|
96
|
+
if (found) {
|
|
97
|
+
widgetCache.set(id, found);
|
|
98
|
+
}
|
|
99
|
+
return found;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let settings: ISettingRegistry.ISettings | null = null;
|
|
103
|
+
|
|
104
|
+
const readPreference = (target: ITarget): boolean => {
|
|
105
|
+
if (!settings) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
const value = settings.composite[target.settingKey];
|
|
109
|
+
return typeof value === 'boolean' ? value : true;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const apply = (target: ITarget): void => {
|
|
113
|
+
const widget = captureWidget(target.id);
|
|
114
|
+
if (!widget) {
|
|
115
|
+
// The upstream widget hasn't been added yet (or the extension is
|
|
116
|
+
// disabled). Nothing to do — the next time `apply` runs and finds
|
|
117
|
+
// it, the preference will take effect.
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const present = findInSidebar(target.id) !== null;
|
|
121
|
+
const wantPresent = readPreference(target);
|
|
122
|
+
if (wantPresent && !present) {
|
|
123
|
+
labShell.add(widget, 'left', { rank: target.rank });
|
|
124
|
+
} else if (!wantPresent && present) {
|
|
125
|
+
// Setting `parent = null` triggers SideBarHandler's
|
|
126
|
+
// `_onWidgetRemoved`, which strips the tab while leaving the
|
|
127
|
+
// widget instance alive for a later re-add.
|
|
128
|
+
widget.parent = null;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const applyAll = (): void => {
|
|
133
|
+
for (const target of TARGETS) {
|
|
134
|
+
apply(target);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
for (const target of TARGETS) {
|
|
139
|
+
commands.addCommand(target.command, {
|
|
140
|
+
label: target.label(trans),
|
|
141
|
+
isToggled: () => readPreference(target),
|
|
142
|
+
execute: async () => {
|
|
143
|
+
const next = !readPreference(target);
|
|
144
|
+
if (settings) {
|
|
145
|
+
try {
|
|
146
|
+
await settings.set(target.settingKey, next);
|
|
147
|
+
// `settings.changed` will fire and drive `apply` + the
|
|
148
|
+
// command-state refresh. Returning here keeps the toggle's
|
|
149
|
+
// single source of truth in the settings registry.
|
|
150
|
+
return;
|
|
151
|
+
} catch (reason) {
|
|
152
|
+
console.error(
|
|
153
|
+
`xtralab: failed to persist ${target.settingKey}`,
|
|
154
|
+
reason
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// No settings registry available (or the write failed): fall
|
|
159
|
+
// back to applying the change in-memory so the toggle still
|
|
160
|
+
// works for the current session.
|
|
161
|
+
apply(target);
|
|
162
|
+
commands.notifyCommandChanged(target.command);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (settingRegistry) {
|
|
168
|
+
try {
|
|
169
|
+
settings = await settingRegistry.load(PLUGIN_ID);
|
|
170
|
+
settings.changed.connect(() => {
|
|
171
|
+
applyAll();
|
|
172
|
+
for (const target of TARGETS) {
|
|
173
|
+
commands.notifyCommandChanged(target.command);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
} catch (reason) {
|
|
177
|
+
console.error('xtralab: failed to load sidebar settings', reason);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
void app.restored.then(() => {
|
|
182
|
+
// Capture references first so a hidden-on-startup widget is still
|
|
183
|
+
// recoverable later. `apply` would also do the lookup, but only on
|
|
184
|
+
// the targets that need to stay visible — by capturing every target
|
|
185
|
+
// here we keep the cache populated for both directions of the
|
|
186
|
+
// toggle.
|
|
187
|
+
for (const target of TARGETS) {
|
|
188
|
+
captureWidget(target.id);
|
|
189
|
+
}
|
|
190
|
+
applyAll();
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export default plugin;
|