xtralab 0.14.0 → 0.15.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.
Files changed (52) hide show
  1. package/lib/customPanel/index.d.ts +7 -0
  2. package/lib/customPanel/index.js +74 -0
  3. package/lib/fileBrowser/commands.js +13 -1
  4. package/lib/fileBrowser/contents.d.ts +11 -0
  5. package/lib/fileBrowser/contents.js +28 -0
  6. package/lib/fileBrowser/dragAndDrop.d.ts +66 -0
  7. package/lib/fileBrowser/dragAndDrop.js +115 -0
  8. package/lib/fileBrowser/fileBrowser.d.ts +2 -0
  9. package/lib/fileBrowser/fileBrowser.js +151 -197
  10. package/lib/fileBrowser/index.js +39 -6
  11. package/lib/fileBrowser/widget.d.ts +44 -5
  12. package/lib/fileBrowser/widget.js +153 -14
  13. package/lib/index.js +4 -0
  14. package/lib/launcher/agents.d.ts +1 -1
  15. package/lib/launcher/agents.js +15 -2
  16. package/lib/launcher/icons.js +15 -4
  17. package/lib/launcher/schemaDefaults.d.ts +2 -9
  18. package/lib/launcher/schemaDefaults.js +37 -13
  19. package/lib/menuBar/icons.d.ts +6 -0
  20. package/lib/menuBar/icons.js +13 -0
  21. package/lib/menuBar/index.d.ts +15 -0
  22. package/lib/menuBar/index.js +164 -0
  23. package/lib/sidebar/index.d.ts +7 -13
  24. package/lib/sidebar/index.js +80 -46
  25. package/lib/terminals/index.d.ts +5 -3
  26. package/lib/terminals/index.js +17 -6
  27. package/lib/terminals/widget.d.ts +29 -25
  28. package/lib/terminals/widget.js +150 -65
  29. package/package.json +1 -1
  30. package/schema/launcher.json +2 -2
  31. package/schema/menu-bar.json +37 -0
  32. package/schema/sidebar.json +47 -7
  33. package/src/customPanel/index.ts +104 -0
  34. package/src/fileBrowser/commands.ts +14 -1
  35. package/src/fileBrowser/contents.ts +30 -0
  36. package/src/fileBrowser/dragAndDrop.ts +178 -0
  37. package/src/fileBrowser/fileBrowser.tsx +183 -225
  38. package/src/fileBrowser/index.ts +41 -6
  39. package/src/fileBrowser/widget.tsx +195 -14
  40. package/src/index.ts +4 -0
  41. package/src/launcher/agents.ts +16 -3
  42. package/src/launcher/icons.ts +16 -4
  43. package/src/launcher/schemaDefaults.ts +43 -13
  44. package/src/menuBar/icons.ts +14 -0
  45. package/src/menuBar/index.ts +195 -0
  46. package/src/sidebar/index.ts +95 -52
  47. package/src/terminals/index.ts +24 -6
  48. package/src/terminals/widget.tsx +186 -87
  49. package/style/base.css +22 -34
  50. package/style/customPanel.css +9 -0
  51. package/style/index.js +1 -0
  52. package/style/terminals.css +19 -77
@@ -1,23 +1,28 @@
1
1
  import * as React from 'react';
2
- import { ReactWidget, Toolbar } from '@jupyterlab/ui-components';
2
+ import { AccordionToolbar, PanelWithToolbar, ReactWidget, Toolbar } from '@jupyterlab/ui-components';
3
3
  import { Signal } from '@lumino/signaling';
4
- import { PanelLayout, Widget } from '@lumino/widgets';
4
+ import { AccordionPanel, PanelLayout, Widget } from '@lumino/widgets';
5
5
  import { FileBrowserComponent } from './fileBrowser';
6
6
  import { xtralabFileBrowserIcon } from './icons';
7
7
  export const FILE_BROWSER_ID = 'xtralab:file-browser';
8
8
  /**
9
- * The CSS class added to the xtralab file browser widget. The selectors that
10
- * bind application context menu items hang off this class, so it must remain
11
- * specific enough not to clash with other browsers (such as the default
12
- * `jp-FileBrowser`).
9
+ * Persisted by the movable-sections plugin, so it must stay stable.
10
+ */
11
+ const TREE_SECTION_ID = 'xtralab-file-tree-section';
12
+ /**
13
+ * Context menu selectors hang off this class, so it sits on the section and
14
+ * keeps matching once the tree moves to another panel.
13
15
  */
14
16
  const FILE_BROWSER_CSS_CLASS = 'jp-xtralab-FileBrowser';
17
+ const PANEL_CSS_CLASS = 'jp-xtralab-FileBrowserPanel';
15
18
  const TOOLBAR_CSS_CLASS = 'jp-xtralab-FileBrowser-toolbar';
16
19
  const CONTENT_CSS_CLASS = 'jp-xtralab-FileBrowser-content';
20
+ const ACCORDION_CSS_CLASS = 'jp-xtralab-FileBrowser-accordion';
21
+ const MOVABLE_SECTION_CLASS = 'jp-movable-section';
17
22
  /**
18
- * Lumino widget that hosts the React-based `@pierre/trees` file browser.
19
- * The widget is laid out with a JupyterLab `Toolbar` on top and the tree
20
- * filling the remaining space.
23
+ * Lumino widget hosting the React-based `@pierre/trees` file browser. The tree
24
+ * and its toolbar form one movable "Files" section, and sections from other
25
+ * panels can be moved in below it.
21
26
  */
22
27
  export class XtralabFileBrowser extends Widget {
23
28
  constructor(options) {
@@ -31,26 +36,37 @@ export class XtralabFileBrowser extends Widget {
31
36
  this._collapseAllRequested = new Signal(this);
32
37
  this._fileFilterVisible = false;
33
38
  this._fileFilterVisibleChanged = new Signal(this);
34
- const layout = (this.layout = new PanelLayout());
39
+ this._accordion = null;
40
+ this._contentChanged = new Signal(this);
41
+ this._sectionAdded = new Signal(this);
42
+ const layout = (this._panelLayout = this.layout = new PanelLayout());
35
43
  this._contentsManager = options.contentsManager;
36
44
  this._docManager = options.docManager;
37
45
  this._onOpenFile = options.onOpenFile;
46
+ this._translator = options.translator;
38
47
  this.id = FILE_BROWSER_ID;
39
48
  this.title.icon = xtralabFileBrowserIcon;
40
49
  this.title.caption = 'xtralab File Browser';
41
- this.addClass(FILE_BROWSER_CSS_CLASS);
50
+ this.addClass(PANEL_CSS_CLASS);
42
51
  this._toolbar = new Toolbar();
43
52
  this._toolbar.addClass(TOOLBAR_CSS_CLASS);
44
53
  this._toolbar.node.setAttribute('aria-label', 'file browser');
45
- layout.addWidget(this._toolbar);
46
54
  this._content = new XtralabFileTreeContent({
47
55
  contentsManager: this._contentsManager,
48
56
  docManager: this._docManager,
49
57
  onOpenFile: this._onOpenFile,
58
+ translator: this._translator,
50
59
  browser: this
51
60
  });
52
61
  this._content.addClass(CONTENT_CSS_CLASS);
53
- layout.addWidget(this._content);
62
+ this._section = new PanelWithToolbar({ toolbar: this._toolbar });
63
+ this._section.id = TREE_SECTION_ID;
64
+ // Only an accordion host draws this; a plain `PanelLayout` shows no title.
65
+ this._section.title.label = 'Files';
66
+ this._section.addClass(FILE_BROWSER_CSS_CLASS);
67
+ this._section.addWidget(this._toolbar);
68
+ this._section.addWidget(this._content);
69
+ layout.addWidget(this._section);
54
70
  }
55
71
  get contentsManager() {
56
72
  return this._contentsManager;
@@ -121,6 +137,129 @@ export class XtralabFileBrowser extends Widget {
121
137
  toggleFileFilter() {
122
138
  this.setFileFilterVisible(!this._fileFilterVisible);
123
139
  }
140
+ get sectionNode() {
141
+ return this._section.node;
142
+ }
143
+ get isEmpty() {
144
+ return this._panelLayout.widgets.length === 0;
145
+ }
146
+ get contentChanged() {
147
+ return this._contentChanged;
148
+ }
149
+ get sectionAdded() {
150
+ return this._sectionAdded;
151
+ }
152
+ get accordionPanel() {
153
+ return this._accordion;
154
+ }
155
+ get sections() {
156
+ if (!this._accordion) {
157
+ return [];
158
+ }
159
+ return this._accordion.widgets.filter(w => w !== this._section);
160
+ }
161
+ /**
162
+ * The toolbar is the section's title node: it is the tree's header row at
163
+ * home, and an accordion host renders it inside the section title.
164
+ */
165
+ getSections() {
166
+ return this._isSectionHome()
167
+ ? [
168
+ {
169
+ id: TREE_SECTION_ID,
170
+ titleNode: this._toolbar.node,
171
+ widget: this._section
172
+ }
173
+ ]
174
+ : [];
175
+ }
176
+ removeSectionById(sectionId) {
177
+ if (sectionId !== TREE_SECTION_ID || !this._isSectionHome()) {
178
+ return null;
179
+ }
180
+ this._section.parent = null;
181
+ if (this._accordion && this._accordion.widgets.length === 0) {
182
+ this._accordion.dispose();
183
+ this._accordion = null;
184
+ }
185
+ // Away from home the toolbar must not offer "Move to …" as well;
186
+ // `getSections` re-marks it on the way back.
187
+ this._toolbar.node.classList.remove(MOVABLE_SECTION_CLASS);
188
+ this._contentChanged.emit();
189
+ return this._section;
190
+ }
191
+ reinsertSection(widget) {
192
+ if (this._accordion) {
193
+ this._accordion.insertWidget(0, widget);
194
+ }
195
+ else {
196
+ this._panelLayout.addWidget(widget);
197
+ this._restoreToolbar();
198
+ }
199
+ this._contentChanged.emit();
200
+ }
201
+ /**
202
+ * The move plugin reconciles persisted moves only against sections announced
203
+ * after `registerSource`, and this panel builds its section in the constructor.
204
+ */
205
+ announceSections() {
206
+ for (const entry of this.getSections()) {
207
+ this._sectionAdded.emit(entry);
208
+ }
209
+ }
210
+ /**
211
+ * The first hosted section moves the tree into an accordion.
212
+ */
213
+ addSection(widget) {
214
+ if (!this._accordion) {
215
+ const accordion = new AccordionPanel({
216
+ layout: AccordionToolbar.createLayout({})
217
+ });
218
+ accordion.addClass(ACCORDION_CSS_CLASS);
219
+ if (this._section.parent === this) {
220
+ accordion.addWidget(this._section);
221
+ }
222
+ accordion.addWidget(widget);
223
+ this._accordion = accordion;
224
+ this._panelLayout.addWidget(accordion);
225
+ }
226
+ else {
227
+ this._accordion.addWidget(widget);
228
+ }
229
+ this._contentChanged.emit();
230
+ }
231
+ removeSectionWidget(widget) {
232
+ if (!this._accordion || widget.parent !== this._accordion) {
233
+ return;
234
+ }
235
+ widget.parent = null;
236
+ const remaining = this._accordion.widgets;
237
+ const treeOnly = remaining.length === 1 && remaining[0] === this._section;
238
+ if (treeOnly || remaining.length === 0) {
239
+ if (treeOnly) {
240
+ if (this._section.isHidden) {
241
+ this._section.show();
242
+ }
243
+ this._panelLayout.addWidget(this._section);
244
+ this._restoreToolbar();
245
+ }
246
+ this._accordion.dispose();
247
+ this._accordion = null;
248
+ }
249
+ this._contentChanged.emit();
250
+ }
251
+ _isSectionHome() {
252
+ const parent = this._section.parent;
253
+ return parent === this || (!!this._accordion && parent === this._accordion);
254
+ }
255
+ /**
256
+ * An accordion host moves the toolbar node into the section title and leaves
257
+ * it orphaned on detach; re-parenting makes the layout attach it again.
258
+ */
259
+ _restoreToolbar() {
260
+ this._toolbar.parent = null;
261
+ this._section.insertWidget(0, this._toolbar);
262
+ }
124
263
  }
125
264
  /**
126
265
  * The React-rendered region of the file browser. Lives inside the host
@@ -132,6 +271,6 @@ class XtralabFileTreeContent extends ReactWidget {
132
271
  this._options = options;
133
272
  }
134
273
  render() {
135
- return (React.createElement(FileBrowserComponent, { contentsManager: this._options.contentsManager, docManager: this._options.docManager, onOpenFile: this._options.onOpenFile, widget: this._options.browser }));
274
+ return (React.createElement(FileBrowserComponent, { contentsManager: this._options.contentsManager, docManager: this._options.docManager, onOpenFile: this._options.onOpenFile, translator: this._options.translator, widget: this._options.browser }));
136
275
  }
137
276
  }
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@ import aboutPlugin from './about';
2
2
  import agentSessionsPlugin from './agentSessions';
3
3
  import askAgentPlugin from './askAgent';
4
4
  import commandBarPlugin from './commandBar';
5
+ import customPanelPlugin from './customPanel';
5
6
  import fileBrowserPlugin from './fileBrowser';
6
7
  import fileTypeIconsPlugin from './fileTypeIcons';
7
8
  import editorBreadcrumbsPlugin from './editorBreadcrumbs';
@@ -9,6 +10,7 @@ import editorIndentPlugin from './editorIndent';
9
10
  import gitPlugins from './git';
10
11
  import highlightPlugin from './highlight';
11
12
  import launcherPlugins from './launcher';
13
+ import menuBarPlugin from './menuBar';
12
14
  import menusPlugin from './menus';
13
15
  import omniboxPlugin from './omnibox';
14
16
  import searchReplacePlugin from './searchReplace';
@@ -32,6 +34,7 @@ const plugins = [
32
34
  agentSessionsPlugin,
33
35
  askAgentPlugin,
34
36
  commandBarPlugin,
37
+ customPanelPlugin,
35
38
  editorBreadcrumbsPlugin,
36
39
  editorIndentPlugin,
37
40
  fileBrowserPlugin,
@@ -39,6 +42,7 @@ const plugins = [
39
42
  ...gitPlugins,
40
43
  highlightPlugin,
41
44
  ...launcherPlugins,
45
+ menuBarPlugin,
42
46
  menusPlugin,
43
47
  omniboxPlugin,
44
48
  searchReplacePlugin,
@@ -23,7 +23,7 @@ export interface IAgent {
23
23
  * the user types one into the launcher's prompt box. The semantics:
24
24
  *
25
25
  * - `[]` → prompt is appended as a positional argument:
26
- * `<command> 'PROMPT'`. (Used by claude, codex, vibe.)
26
+ * `<command> 'PROMPT'`. (Used by claude, codex, vibe, pi.)
27
27
  * - `['-i']` / `['--prompt']` → the prompt is preceded by a flag:
28
28
  * `<command> -i 'PROMPT'`. (Used by copilot, opencode.)
29
29
  * - `undefined` → the agent does not accept an initial prompt. The
@@ -7,8 +7,9 @@ import { BUILTIN_AGENT_ICONS } from './icons';
7
7
  * icons come from), but use the bare CLI names a user would type in a
8
8
  * terminal rather than the ACP wrapper binaries that project spawns.
9
9
  * Antigravity (Google's agent-first IDE/CLI, `agy`) is the Google agent.
10
- * Anything not on the user's `$PATH` is filtered out at activation time, so
11
- * the wider list is harmless.
10
+ * Pi (https://pi.dev, `earendil-works/pi`) is a minimal extensible coding
11
+ * agent; its mark comes straight from its site. Anything not on the user's
12
+ * `$PATH` is filtered out at activation time, so the wider list is harmless.
12
13
  */
13
14
  const DEFAULTS = [
14
15
  {
@@ -100,6 +101,18 @@ const DEFAULTS = [
100
101
  rank: 7,
101
102
  requireAvailable: true,
102
103
  promptArgs: []
104
+ },
105
+ {
106
+ id: 'pi',
107
+ label: 'Pi',
108
+ caption: 'Start Pi in a new terminal.',
109
+ command: 'pi',
110
+ icon: BUILTIN_AGENT_ICONS.pi,
111
+ rank: 8,
112
+ requireAvailable: true,
113
+ // `pi [messages...]` starts the interactive TUI and sends the messages
114
+ // as the first user prompt, so the positional form applies.
115
+ promptArgs: []
103
116
  }
104
117
  ];
105
118
  /**
@@ -5,11 +5,14 @@ import { LabIcon } from '@jupyterlab/ui-components';
5
5
  * (`jupyter_ai_acp_client/static/*.svg`, BSD-3-Clause); Antigravity post-dates
6
6
  * that set, so its mark is the official glyph from `@lobehub/icons-static-svg`
7
7
  * (MIT) — we ship the brand-colored `antigravity-color` variant so Antigravity
8
- * reads as a multi-color Google mark. Small adjustments are applied
8
+ * reads as a multi-color Google mark. Pi's mark is the official `logo-auto.svg`
9
+ * from https://pi.dev (MIT, `earendil-works/pi`). Small adjustments are applied
9
10
  * to play nicely with JupyterLab themes:
10
- * - Monochrome marks (Codex, Copilot, Goose) use `currentColor` so they
11
+ * - Monochrome marks (Codex, Copilot, Goose, Pi) use `currentColor` so they
11
12
  * inherit the surrounding launcher card's text color rather than
12
- * hard-coding black, which disappears on the dark theme.
13
+ * hard-coding black, which disappears on the dark theme. For Pi this
14
+ * replaces the upstream `prefers-color-scheme` style block, which follows
15
+ * the OS scheme rather than the active JupyterLab theme.
13
16
  * - The Goose mark upstream is rendered on a white rounded rect; we drop
14
17
  * the rect so the silhouette can sit on the launcher card directly.
15
18
  * - SVG ids that would collide if two copies of the same artwork ended up
@@ -169,6 +172,13 @@ const opencodeIcon = new LabIcon({
169
172
  <path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"/>
170
173
  </svg>`
171
174
  });
175
+ const piIcon = new LabIcon({
176
+ name: 'xtralab:agent-pi',
177
+ svgstr: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" fill="currentColor">
178
+ <path fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/>
179
+ <path d="M517.36 400H634.72V634.72H517.36Z"/>
180
+ </svg>`
181
+ });
172
182
  const neovimIcon = new LabIcon({
173
183
  name: 'xtralab:editor-neovim',
174
184
  svgstr: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#57A143">
@@ -195,7 +205,8 @@ export const BUILTIN_AGENT_ICONS = {
195
205
  goose: gooseIcon,
196
206
  kiro: kiroIcon,
197
207
  'mistral-vibe': mistralVibeIcon,
198
- opencode: opencodeIcon
208
+ opencode: opencodeIcon,
209
+ pi: piIcon
199
210
  };
200
211
  /**
201
212
  * Built-in icons keyed by the editor's id, used to resolve the icon for a
@@ -4,14 +4,7 @@ import type { ISettingRegistry } from '@jupyterlab/settingregistry';
4
4
  */
5
5
  export declare const LAUNCHER_PLUGIN_ID = "xtralab:launcher";
6
6
  /**
7
- * Register a `fetch` transform that injects xtralab's built-in agent and
8
- * editor lists as the schema defaults for the `xtralab:launcher` settings, so
9
- * the Settings Editor shows the shipped list.
10
- *
11
- * The schema declares `jupyter.lab.transform: true`, so the registry defers
12
- * loading the plugin until a transform is registered: callers must invoke this
13
- * before their first `settingRegistry.load(LAUNCHER_PLUGIN_ID)`. Idempotent, so
14
- * the two plugins that load these settings can both call it regardless of
15
- * activation order.
7
+ * Must run before the first `load(LAUNCHER_PLUGIN_ID)` (the schema sets
8
+ * `jupyter.lab.transform: true`); idempotent across callers.
16
9
  */
17
10
  export declare function registerLauncherSchemaDefaults(settingRegistry: ISettingRegistry): void;
@@ -5,20 +5,38 @@ import { defaultEditorSettings } from './editors';
5
5
  */
6
6
  export const LAUNCHER_PLUGIN_ID = 'xtralab:launcher';
7
7
  /**
8
- * Setting registries that already have the transform, so both callers skip
9
- * re-registering (the registry rejects a duplicate transformer).
8
+ * Registries that already have the transform (duplicates are rejected).
10
9
  */
11
10
  const registered = new WeakSet();
12
11
  /**
13
- * Register a `fetch` transform that injects xtralab's built-in agent and
14
- * editor lists as the schema defaults for the `xtralab:launcher` settings, so
15
- * the Settings Editor shows the shipped list.
16
- *
17
- * The schema declares `jupyter.lab.transform: true`, so the registry defers
18
- * loading the plugin until a transform is registered: callers must invoke this
19
- * before their first `settingRegistry.load(LAUNCHER_PLUGIN_ID)`. Idempotent, so
20
- * the two plugins that load these settings can both call it regardless of
21
- * activation order.
12
+ * Merge sparse user entries over the built-in defaults, keyed by `id`.
13
+ */
14
+ function mergeSettingsById(defaults, user) {
15
+ const entries = Array.isArray(user) ? user : [];
16
+ const overrideById = new Map(entries.map(entry => [entry.id, entry]));
17
+ const merged = defaults.map(base => {
18
+ const override = overrideById.get(base.id);
19
+ if (!override) {
20
+ return base;
21
+ }
22
+ overrideById.delete(base.id);
23
+ return { ...base, ...override };
24
+ });
25
+ merged.push(...overrideById.values());
26
+ // Item schema defaults are applied before this transform runs; without the
27
+ // refill, sparse entries render with unchecked boxes.
28
+ return merged.map(entry => {
29
+ var _a, _b;
30
+ return ({
31
+ ...entry,
32
+ enabled: (_a = entry.enabled) !== null && _a !== void 0 ? _a : true,
33
+ requireAvailable: (_b = entry.requireAvailable) !== null && _b !== void 0 ? _b : true
34
+ });
35
+ });
36
+ }
37
+ /**
38
+ * Must run before the first `load(LAUNCHER_PLUGIN_ID)` (the schema sets
39
+ * `jupyter.lab.transform: true`); idempotent across callers.
22
40
  */
23
41
  export function registerLauncherSchemaDefaults(settingRegistry) {
24
42
  if (registered.has(settingRegistry)) {
@@ -26,11 +44,17 @@ export function registerLauncherSchemaDefaults(settingRegistry) {
26
44
  }
27
45
  registered.add(settingRegistry);
28
46
  settingRegistry.transform(LAUNCHER_PLUGIN_ID, {
47
+ compose: plugin => {
48
+ const { user } = plugin.data;
49
+ const composite = { ...plugin.data.composite };
50
+ composite.agents = mergeSettingsById(defaultAgentSettings(), user.agents);
51
+ composite.editors = mergeSettingsById(defaultEditorSettings(), user.editors);
52
+ plugin.data = { composite, user };
53
+ return plugin;
54
+ },
29
55
  fetch: plugin => {
30
56
  const properties = plugin.schema.properties;
31
57
  if (properties) {
32
- // The settings shapes are plain JSON by construction, but their named
33
- // interfaces lack an index signature, so cast at this TS→JSON boundary.
34
58
  if (properties.agents) {
35
59
  properties.agents.default =
36
60
  defaultAgentSettings();
@@ -0,0 +1,6 @@
1
+ import { LabIcon } from '@jupyterlab/ui-components';
2
+ /**
3
+ * Hamburger mark for the collapsed main-menu button, matching the sidebar
4
+ * toggles in ../topBar/icons.ts.
5
+ */
6
+ export declare const mainMenuIcon: LabIcon;
@@ -0,0 +1,13 @@
1
+ import { LabIcon } from '@jupyterlab/ui-components';
2
+ /**
3
+ * Hamburger mark for the collapsed main-menu button, matching the sidebar
4
+ * toggles in ../topBar/icons.ts.
5
+ */
6
+ export const mainMenuIcon = new LabIcon({
7
+ name: 'xtralab:main-menu',
8
+ svgstr: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
9
+ <line x1="4.5" y1="7" x2="19.5" y2="7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
10
+ <line x1="4.5" y1="12" x2="19.5" y2="12" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
11
+ <line x1="4.5" y1="17" x2="19.5" y2="17" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
12
+ </svg>`
13
+ });
@@ -0,0 +1,15 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * Collapse the main menu bar into a compact menu button.
4
+ *
5
+ * The menu bar is hidden and a hamburger button in the top bar opens the same
6
+ * menus as a vertical popup. The popup reuses the live `RankedMenu` instances
7
+ * owned by the `MainMenu`, so menus added or removed at runtime are reflected
8
+ * without duplication.
9
+ *
10
+ * Hiding covers the `jp-menu-panel` container too, which has its own
11
+ * `min-height` and would otherwise leave an empty strip. The "Show Menu Bar"
12
+ * toggle restores the classic bar.
13
+ */
14
+ declare const plugin: JupyterFrontEndPlugin<void>;
15
+ export default plugin;
@@ -0,0 +1,164 @@
1
+ import { ILabShell } from '@jupyterlab/application';
2
+ import { ICommandPalette } from '@jupyterlab/apputils';
3
+ import { IMainMenu, MainMenu } from '@jupyterlab/mainmenu';
4
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
5
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
6
+ import { CommandToolbarButton, MenuSvg } from '@jupyterlab/ui-components';
7
+ import { Menu } from '@lumino/widgets';
8
+ import { mainMenuIcon } from './icons';
9
+ const PLUGIN_ID = 'xtralab:menu-bar';
10
+ const OPEN_COMMAND = 'xtralab:open-main-menu';
11
+ const TOGGLE_COMMAND = 'xtralab:toggle-menu-bar';
12
+ /**
13
+ * How long after the popup closes a button press still counts as "close
14
+ * only". An open Lumino menu closes itself on any outside press from a
15
+ * document `pointerdown` listener in the capture phase, which runs before
16
+ * the button's own mousedown handler — so without this window, pressing the
17
+ * button while the popup is open would close it and instantly reopen it.
18
+ */
19
+ const REOPEN_GUARD_MS = 250;
20
+ /**
21
+ * Collapse the main menu bar into a compact menu button.
22
+ *
23
+ * The menu bar is hidden and a hamburger button in the top bar opens the same
24
+ * menus as a vertical popup. The popup reuses the live `RankedMenu` instances
25
+ * owned by the `MainMenu`, so menus added or removed at runtime are reflected
26
+ * without duplication.
27
+ *
28
+ * Hiding covers the `jp-menu-panel` container too, which has its own
29
+ * `min-height` and would otherwise leave an empty strip. The "Show Menu Bar"
30
+ * toggle restores the classic bar.
31
+ */
32
+ const plugin = {
33
+ id: PLUGIN_ID,
34
+ description: 'Collapse the main menu bar into a compact menu button.',
35
+ autoStart: true,
36
+ requires: [IMainMenu, ILabShell, ISettingRegistry],
37
+ optional: [ICommandPalette, ITranslator],
38
+ activate: (app, mainMenu, labShell, settingRegistry, palette, translator) => {
39
+ var _a;
40
+ const { commands } = app;
41
+ const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
42
+ if (!(mainMenu instanceof MainMenu)) {
43
+ return;
44
+ }
45
+ const menuPanel = () => {
46
+ var _a, _b;
47
+ return (_b = (_a = mainMenu.parent) !== null && _a !== void 0 ? _a : Array.from(labShell.widgets('top')).find(w => w.id === 'jp-menu-panel')) !== null && _b !== void 0 ? _b : null;
48
+ };
49
+ let visible = false;
50
+ let button = null;
51
+ let settings = null;
52
+ const applyVisibility = (value) => {
53
+ var _a;
54
+ visible = value;
55
+ mainMenu.setHidden(!value);
56
+ (_a = menuPanel()) === null || _a === void 0 ? void 0 : _a.setHidden(!value);
57
+ button === null || button === void 0 ? void 0 : button.setHidden(value);
58
+ commands.notifyCommandChanged(TOGGLE_COMMAND);
59
+ };
60
+ mainMenu.hide();
61
+ (_a = menuPanel()) === null || _a === void 0 ? void 0 : _a.hide();
62
+ let popup = null;
63
+ let popupClosedAt = 0;
64
+ const openMenu = () => {
65
+ var _a, _b;
66
+ if (visible) {
67
+ // The classic bar is on screen; behave like keyboard menu
68
+ // activation instead of opening a redundant popup that would
69
+ // compete with the bar for the same Menu instances.
70
+ mainMenu.activeIndex = 0;
71
+ mainMenu.openActiveMenu();
72
+ return;
73
+ }
74
+ if (Date.now() - popupClosedAt < REOPEN_GUARD_MS) {
75
+ return;
76
+ }
77
+ // One popup instance, rebuilt from the bar's current menus on every
78
+ // open so runtime menu changes stay reflected. It is a plain Lumino
79
+ // Menu rather than a MenuSvg: MenuSvg.insertItem re-wraps each
80
+ // submenu's renderer and insertItem on every call, which would pile
81
+ // wrappers onto the shared menus across reopens. The svg renderer and
82
+ // themed-container class are applied directly instead, matching how
83
+ // the real menus are constructed in MainMenu.
84
+ if (popup === null) {
85
+ popup = new Menu({ commands, renderer: MenuSvg.defaultRenderer });
86
+ popup.addClass('jp-ThemedContainer');
87
+ popup.addClass('jp-xtralab-MainMenuPopup');
88
+ popup.aboutToClose.connect(() => {
89
+ popupClosedAt = Date.now();
90
+ });
91
+ }
92
+ else {
93
+ popup.clearItems();
94
+ }
95
+ // Skip empty menus: MenuBar disables their headings, but submenu items
96
+ // have no such affordance and would open a blank panel.
97
+ for (const menu of mainMenu.menus) {
98
+ if (menu.items.length > 0) {
99
+ popup.addItem({ type: 'submenu', submenu: menu });
100
+ }
101
+ }
102
+ const anchor = button === null || button === void 0 ? void 0 : button.node.getBoundingClientRect();
103
+ // The 3px gap matches the detached-dropdown offset chrome.css gives
104
+ // the real menu bar's dropdowns (.lm-Menu.lm-MenuBar-menu).
105
+ popup.open((_a = anchor === null || anchor === void 0 ? void 0 : anchor.left) !== null && _a !== void 0 ? _a : 0, ((_b = anchor === null || anchor === void 0 ? void 0 : anchor.bottom) !== null && _b !== void 0 ? _b : 0) + 3);
106
+ };
107
+ commands.addCommand(OPEN_COMMAND, {
108
+ label: trans.__('Open Menu'),
109
+ caption: trans.__('Open the main menu'),
110
+ icon: mainMenuIcon,
111
+ execute: openMenu
112
+ });
113
+ commands.addCommand(TOGGLE_COMMAND, {
114
+ label: trans.__('Show Menu Bar'),
115
+ caption: trans.__('Toggle the visibility of the main menu bar'),
116
+ isToggled: () => visible,
117
+ execute: async () => {
118
+ applyVisibility(!visible);
119
+ if (settings !== null) {
120
+ try {
121
+ await settings.set('visible', visible);
122
+ }
123
+ catch (reason) {
124
+ console.error('xtralab: failed to persist menu bar visibility', reason);
125
+ }
126
+ }
127
+ }
128
+ });
129
+ if (palette) {
130
+ const category = trans.__('Other');
131
+ palette.addItem({ command: OPEN_COMMAND, category });
132
+ palette.addItem({ command: TOGGLE_COMMAND, category });
133
+ }
134
+ Promise.all([settingRegistry.load(PLUGIN_ID), app.restored])
135
+ .then(([loaded]) => {
136
+ settings = loaded;
137
+ button = new CommandToolbarButton({
138
+ commands,
139
+ id: OPEN_COMMAND,
140
+ icon: mainMenuIcon,
141
+ label: '',
142
+ caption: trans.__('Menu'),
143
+ noFocusOnClick: true,
144
+ 'aria-haspopup': 'menu'
145
+ });
146
+ button.id = 'xtralab-main-menu-button';
147
+ button.addClass('jp-xtralab-TopBarButton');
148
+ labShell.add(button, 'top', { rank: 0 });
149
+ const update = () => {
150
+ applyVisibility(loaded.get('visible').composite);
151
+ };
152
+ update();
153
+ loaded.changed.connect(update);
154
+ })
155
+ .catch(reason => {
156
+ // Without settings the collapsed state cannot be managed (and the
157
+ // button was never added) — reveal the stock bar rather than leave
158
+ // the app with no reachable menus.
159
+ applyVisibility(true);
160
+ console.error(`Failed to load settings for ${PLUGIN_ID}`, reason);
161
+ });
162
+ }
163
+ };
164
+ export default plugin;
@@ -1,18 +1,12 @@
1
1
  import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
2
  /**
3
- * Lets the user toggle individual left-sidebar tabs from
4
- * View > Appearance > Left Sidebar. Removing a widget from a `LabShell`
5
- * side area is done by setting `widget.parent = null`: the StackedPanel
6
- * emits `widgetRemoved`, which the SideBarHandler responds to by removing
7
- * the matching tab. The widget instance is preserved, so we can re-add it
8
- * later via `labShell.add` with its original rank.
9
- *
10
- * The menu placement is contributed declaratively in
11
- * `schema/sidebar.json`, so this plugin only registers commands and
12
- * applies state — it does not touch `IMainMenu` directly.
13
- *
14
- * The preference is persisted to `xtralab:sidebar` settings so the choice
15
- * survives reloads.
3
+ * Toggles individual sidebar tabs from View > Appearance > Sidebars,
4
+ * whichever side a tab currently lives on. Hiding detaches the widget
5
+ * (`widget.parent = null`) while keeping the instance for a later
6
+ * re-add; the preference persists in this plugin's settings. Which side
7
+ * a hidden tab came from is only tracked in-memory, so after a reload a
8
+ * re-shown tab lands on the left. The menu placement is contributed
9
+ * declaratively in `schema/sidebar.json`.
16
10
  */
17
11
  declare const plugin: JupyterFrontEndPlugin<void>;
18
12
  export default plugin;