xtralab 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Identity of an entry: two uses only match when kind, id and args all do,
3
+ * so runs of one command with different args stay distinct entries.
4
+ */
5
+ function entryKey(entry) {
6
+ var _a;
7
+ return `${entry.kind}\0${entry.id}\0${JSON.stringify((_a = entry.args) !== null && _a !== void 0 ? _a : null)}`;
8
+ }
1
9
  /**
2
10
  * State database key the recents list is persisted under.
3
11
  */
@@ -45,11 +53,14 @@ export class OmniboxRecents {
45
53
  : [...this._entries];
46
54
  }
47
55
  /**
48
- * Record a use of `id`, moving it to the front of its kind's list.
56
+ * Record a use of `id` (with the args it ran with, if any), moving it to
57
+ * the front of its kind's list.
49
58
  */
50
- touch(kind, id) {
51
- const rest = this._entries.filter(entry => !(entry.kind === kind && entry.id === id));
52
- this._entries = this._trim([{ kind, id }, ...rest]);
59
+ touch(kind, id, args) {
60
+ const entry = args === undefined ? { kind, id } : { kind, id, args };
61
+ const key = entryKey(entry);
62
+ const rest = this._entries.filter(existing => entryKey(existing) !== key);
63
+ this._entries = this._trim([entry, ...rest]);
53
64
  this._save();
54
65
  }
55
66
  /**
@@ -68,16 +79,31 @@ export class OmniboxRecents {
68
79
  return;
69
80
  }
70
81
  const persisted = Array.isArray(fetched)
71
- ? fetched.filter((entry) => !!entry &&
72
- typeof entry === 'object' &&
73
- (entry.kind === 'command' ||
74
- entry.kind === 'file') &&
75
- typeof entry.id === 'string')
82
+ ? fetched.filter((entry) => {
83
+ if (!entry || typeof entry !== 'object') {
84
+ return false;
85
+ }
86
+ const candidate = entry;
87
+ if (candidate.kind !== 'command' && candidate.kind !== 'file') {
88
+ return false;
89
+ }
90
+ if (typeof candidate.id !== 'string') {
91
+ return false;
92
+ }
93
+ const args = candidate.args;
94
+ return (args === undefined ||
95
+ (typeof args === 'object' && args !== null && !Array.isArray(args)));
96
+ })
76
97
  : [];
98
+ const known = new Set(this._entries.map(entryKey));
77
99
  const merged = [...this._entries];
78
100
  for (const entry of persisted) {
79
- if (!merged.some(existing => existing.kind === entry.kind && existing.id === entry.id)) {
80
- merged.push({ kind: entry.kind, id: entry.id });
101
+ const key = entryKey(entry);
102
+ if (!known.has(key)) {
103
+ known.add(key);
104
+ merged.push(entry.args === undefined
105
+ ? { kind: entry.kind, id: entry.id }
106
+ : { kind: entry.kind, id: entry.id, args: entry.args });
81
107
  }
82
108
  }
83
109
  this._entries = this._trim(merged);
@@ -98,10 +124,9 @@ export class OmniboxRecents {
98
124
  if (!this._state) {
99
125
  return;
100
126
  }
101
- const value = this._entries.map(entry => ({
102
- kind: entry.kind,
103
- id: entry.id
104
- }));
127
+ const value = this._entries.map(entry => entry.args === undefined
128
+ ? { kind: entry.kind, id: entry.id }
129
+ : { kind: entry.kind, id: entry.id, args: entry.args });
105
130
  this._state.save(STATE_KEY, value).catch(reason => {
106
131
  console.error('xtralab omnibox: failed to save recents', reason);
107
132
  });
@@ -2,6 +2,7 @@ import type { DocumentRegistry } from '@jupyterlab/docregistry';
2
2
  import type { TranslationBundle } from '@jupyterlab/translation';
3
3
  import { ReactWidget } from '@jupyterlab/ui-components';
4
4
  import type { CommandRegistry } from '@lumino/commands';
5
+ import type { CommandPalette } from '@lumino/widgets';
5
6
  import type { IAgent } from '../launcher/agents';
6
7
  import type { OmniboxRecents } from './recents';
7
8
  /**
@@ -24,6 +25,11 @@ export declare namespace OmniboxWidget {
24
25
  */
25
26
  interface IOptions {
26
27
  commands: CommandRegistry;
28
+ /**
29
+ * Snapshot of the command palette's items, read once when the overlay
30
+ * opens; empty when the palette isn't available.
31
+ */
32
+ paletteItems: ReadonlyArray<CommandPalette.IItem>;
27
33
  docRegistry: DocumentRegistry;
28
34
  /**
29
35
  * Snapshot of the available agents, read once when the overlay opens.
@@ -39,7 +39,7 @@ function renderIcon(icon) {
39
39
  return icon ? (React.createElement(icon.react, { tag: "span", className: "jp-xtralab-Omnibox-itemIcon" })) : (React.createElement("span", { className: "jp-xtralab-Omnibox-itemIcon" }));
40
40
  }
41
41
  function OmniboxComponent(props) {
42
- const { commands, docRegistry, agents, placeholder, initialQuery, recents, trans, onClose } = props;
42
+ const { commands, paletteItems, docRegistry, agents, placeholder, initialQuery, recents, trans, onClose } = props;
43
43
  const [query, setQuery] = React.useState(initialQuery);
44
44
  const [files, setFiles] = React.useState([]);
45
45
  const [active, setActive] = React.useState(0);
@@ -63,12 +63,13 @@ function OmniboxComponent(props) {
63
63
  const sections = React.useMemo(() => computeSections({
64
64
  query,
65
65
  commands,
66
+ paletteItems,
66
67
  docRegistry,
67
68
  agents,
68
69
  files,
69
70
  recents,
70
71
  trans
71
- }), [query, commands, docRegistry, agents, files, recents, trans]);
72
+ }), [query, commands, paletteItems, docRegistry, agents, files, recents, trans]);
72
73
  const flat = React.useMemo(() => [
73
74
  ...sections.recent,
74
75
  ...sections.commands,
package/logo.svg ADDED
@@ -0,0 +1,63 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -3 194 272" role="img" aria-labelledby="jp-xtralab-logo-title jp-xtralab-logo-desc">
2
+ <title id="jp-xtralab-logo-title">XtraLab flask icon</title>
3
+ <desc id="jp-xtralab-logo-desc">A clean, slightly tilted laboratory flask with a symmetrical constant-width dark frame, blue-to-pink liquid, bubbles, and rising pixel blocks.</desc>
4
+
5
+ <defs>
6
+ <linearGradient id="jp-xtralab-logo-liquid" x1="20" y1="154" x2="151" y2="231" gradientUnits="userSpaceOnUse">
7
+ <stop offset="0" stop-color="#00a6ed"/>
8
+ <stop offset=".29" stop-color="#0879ed"/>
9
+ <stop offset=".58" stop-color="#6548d2"/>
10
+ <stop offset=".8" stop-color="#b43baa"/>
11
+ <stop offset="1" stop-color="#f1357e"/>
12
+ </linearGradient>
13
+ <radialGradient id="jp-xtralab-logo-liquid-glow" cx="0" cy="0" r="1"
14
+ gradientTransform="translate(49 169) rotate(34) scale(75 49)"
15
+ gradientUnits="userSpaceOnUse">
16
+ <stop stop-color="#18b9ef" stop-opacity=".78"/>
17
+ <stop offset=".58" stop-color="#1688ef" stop-opacity=".2"/>
18
+ <stop offset="1" stop-color="#1688ef" stop-opacity="0"/>
19
+ </radialGradient>
20
+ <linearGradient id="jp-xtralab-logo-pixel-blue" x1="137" y1="13" x2="156" y2="32" gradientUnits="userSpaceOnUse">
21
+ <stop stop-color="#05aae9"/>
22
+ <stop offset="1" stop-color="#008be0"/>
23
+ </linearGradient>
24
+ <linearGradient id="jp-xtralab-logo-pixel-purple" x1="154" y1="39" x2="178" y2="61" gradientUnits="userSpaceOnUse">
25
+ <stop stop-color="#6242c0"/>
26
+ <stop offset="1" stop-color="#923ba8"/>
27
+ </linearGradient>
28
+ <linearGradient id="jp-xtralab-logo-pixel-pink" x1="166" y1="0" x2="183" y2="17" gradientUnits="userSpaceOnUse">
29
+ <stop stop-color="#8040bd"/>
30
+ <stop offset="1" stop-color="#ad3c9b"/>
31
+ </linearGradient>
32
+ <clipPath id="jp-xtralab-logo-chamber" clipPathUnits="userSpaceOnUse">
33
+ <path d="M64 61h36v43c0 10 2 18 8 27l42 73c5 8 5 18 0 26-5 8-13 12-23 13H37c-10-1-18-5-23-13-5-8-5-18 0-26l42-73c6-9 8-17 8-27Z"/>
34
+ </clipPath>
35
+ </defs>
36
+
37
+ <!-- The flask is constructed symmetrically around x=82, then given a slight logo-matching tilt. -->
38
+ <g transform="rotate(9 82 232)">
39
+ <g clip-path="url(#jp-xtralab-logo-chamber)">
40
+ <path fill="url(#jp-xtralab-logo-liquid)"
41
+ d="M8 207c17-25 34-52 55-60 16-6 29-2 42 5 14 8 27 8 43-4l13 65c4 18-8 35-27 37H30c-20-2-30-23-22-43Z"/>
42
+ <path fill="url(#jp-xtralab-logo-liquid-glow)"
43
+ d="M8 207c17-25 34-52 55-60 16-6 29-2 42 5 14 8 27 8 43-4l13 65c4 18-8 35-27 37H30c-20-2-30-23-22-43Z"/>
44
+ </g>
45
+
46
+ <!-- A straight integrated mouth joins the widened chimney without a separate rim element. -->
47
+ <path d="M56 55v49c0 12-2 21-8 31L9 201c-7 10-7 23-1 33s16 15 28 16h92c12-1 22-6 28-16s6-23-1-33l-39-66c-6-10-8-19-8-31V55H56Z"
48
+ fill="none" stroke="#07111d" stroke-width="16" stroke-linejoin="round" stroke-linecap="round"/>
49
+ <path d="M56 55v49c0 12-2 21-8 31L9 201c-7 10-7 23-1 33s16 15 28 16h92c12-1 22-6 28-16s6-23-1-33l-39-66c-6-10-8-19-8-31V55H56Z"
50
+ fill="none" stroke="#263b57" stroke-width="12" stroke-linejoin="round" stroke-linecap="round"/>
51
+
52
+ <circle cx="82" cy="80" r="4.7" fill="#009fe9" stroke="#0b1d35" stroke-width=".7"/>
53
+ <circle cx="82" cy="104" r="6.2" fill="#078ae9" stroke="#0b1d35" stroke-width=".7"/>
54
+ <circle cx="82" cy="128" r="7.2" fill="#3256d9" stroke="#0b1d35" stroke-width=".7"/>
55
+ </g>
56
+
57
+ <g stroke="#17233c" stroke-width=".75">
58
+ <rect x="116" y="22" width="13" height="13" rx="2" fill="#7342bd"/>
59
+ <rect x="137" y="13" width="19" height="19" rx="1.5" fill="url(#jp-xtralab-logo-pixel-blue)"/>
60
+ <rect x="154" y="39" width="24" height="22" rx="1.5" fill="url(#jp-xtralab-logo-pixel-purple)"/>
61
+ <rect x="166" y=".5" width="17" height="17" rx="1.5" fill="url(#jp-xtralab-logo-pixel-pink)"/>
62
+ </g>
63
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtralab",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "An opinionated JupyterLab meta-package that bundles a curated set of extensions, ships a path-first file browser, renders jupyterlab-git's text, notebook and image diffs with its own viewers, and applies a quieter default workspace configuration.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -21,7 +21,8 @@
21
21
  "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
22
22
  "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
23
23
  "src/**/*.{ts,tsx}",
24
- "schema/*.json"
24
+ "schema/*.json",
25
+ "logo.svg"
25
26
  ],
26
27
  "main": "lib/index.js",
27
28
  "types": "lib/index.d.ts",
@@ -141,6 +142,7 @@
141
142
  "@jupyterlab/git:notebook-diff",
142
143
  "@jupyterlab/git:plain-text-diff",
143
144
  "@jupyterlab/git:image-diff",
145
+ "@jupyterlab/help-extension:about",
144
146
  "@jupyterlab/launcher-extension"
145
147
  ]
146
148
  },
@@ -0,0 +1,24 @@
1
+ {
2
+ "jupyter.lab.menus": {
3
+ "main": [
4
+ {
5
+ "id": "jp-mainmenu-help",
6
+ "items": [
7
+ {
8
+ "command": "help:about",
9
+ "rank": 0
10
+ },
11
+ {
12
+ "type": "separator",
13
+ "rank": 0
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ },
19
+ "title": "xtralab About",
20
+ "description": "The About dialog showing the running xtralab and JupyterLab versions.",
21
+ "type": "object",
22
+ "properties": {},
23
+ "additionalProperties": false
24
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "title": "xtralab menu bar",
3
+ "description": "Visibility of the main menu bar.",
4
+ "jupyter.lab.shortcuts": [],
5
+ "jupyter.lab.menus": {
6
+ "main": [
7
+ {
8
+ "id": "jp-mainmenu-view",
9
+ "items": [
10
+ {
11
+ "type": "submenu",
12
+ "rank": 1,
13
+ "submenu": {
14
+ "id": "jp-mainmenu-view-appearance",
15
+ "items": [
16
+ {
17
+ "command": "xtralab:toggle-menu-bar",
18
+ "rank": 14
19
+ }
20
+ ]
21
+ }
22
+ }
23
+ ]
24
+ }
25
+ ]
26
+ },
27
+ "type": "object",
28
+ "properties": {
29
+ "visible": {
30
+ "type": "boolean",
31
+ "title": "Show the menu bar",
32
+ "description": "When false, hides the main menu bar. Its menus stay available from the menu button at the leading edge of the top bar and the bar can be restored from View → Appearance → Show Menu Bar.",
33
+ "default": false
34
+ }
35
+ },
36
+ "additionalProperties": false
37
+ }
@@ -0,0 +1,13 @@
1
+ import { LabIcon } from '@jupyterlab/ui-components';
2
+
3
+ import logoSvgstr from '../../logo.svg';
4
+
5
+ /**
6
+ * The project logo mark — `logo.svg` at the repository root, the same file
7
+ * the README and the docs site use. The labextension build inlines it as a
8
+ * raw string, so the dialog always shows the current artwork.
9
+ */
10
+ export const aboutLogoIcon = new LabIcon({
11
+ name: 'xtralab:about-logo',
12
+ svgstr: logoSvgstr
13
+ });
@@ -0,0 +1,125 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+ import { Dialog, ICommandPalette, showDialog } from '@jupyterlab/apputils';
6
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
7
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
8
+ import * as React from 'react';
9
+
10
+ import { aboutLogoIcon } from './icons';
11
+
12
+ const PLUGIN_ID = 'xtralab:about';
13
+
14
+ /**
15
+ * Upstream command id. Reusing it keeps the Help menu entry (contributed by
16
+ * `schema/about.json` at the same rank as upstream), the command palette
17
+ * muscle memory, and any external caller of `help:about` working.
18
+ */
19
+ const CommandIDs = {
20
+ about: 'help:about'
21
+ };
22
+
23
+ const REPO_URL = 'https://github.com/jtpio/xtralab';
24
+
25
+ /**
26
+ * An "About xtralab" dialog replacing the stock "About JupyterLab" one.
27
+ *
28
+ * The upstream `@jupyterlab/help-extension:about` plugin is disabled via
29
+ * `jupyterlab.disabledExtensions` (package.json), which removes both its
30
+ * `help:about` command and its Help menu contribution; this plugin registers
31
+ * the same command id and restores the menu entry through its own schema.
32
+ * The dialog leads with the xtralab version and keeps the underlying
33
+ * JupyterLab version visible underneath.
34
+ */
35
+ const plugin: JupyterFrontEndPlugin<void> = {
36
+ id: PLUGIN_ID,
37
+ description: 'Show an About dialog with the xtralab and JupyterLab versions.',
38
+ autoStart: true,
39
+ requires: [ISettingRegistry],
40
+ optional: [ICommandPalette, ITranslator],
41
+ activate: (
42
+ app: JupyterFrontEnd,
43
+ settingRegistry: ISettingRegistry,
44
+ palette: ICommandPalette | null,
45
+ translator: ITranslator | null
46
+ ): void => {
47
+ const { commands } = app;
48
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
49
+
50
+ commands.addCommand(CommandIDs.about, {
51
+ label: trans.__('About %1', 'xtralab'),
52
+ describedBy: {
53
+ args: {
54
+ type: 'object',
55
+ properties: {}
56
+ }
57
+ },
58
+ execute: async () => {
59
+ // The settings payload carries the version of the installed
60
+ // labextension (read server-side from its package.json), which is
61
+ // the release actually running — the page config only knows the
62
+ // JupyterLab version. 'N/A' is jupyterlab_server's not-found value.
63
+ const version = await settingRegistry
64
+ .load(PLUGIN_ID)
65
+ .then(settings =>
66
+ settings.version === 'N/A' ? '' : settings.version
67
+ )
68
+ .catch(() => '');
69
+
70
+ const title = (
71
+ <span className="jp-xtralab-About-header">
72
+ <aboutLogoIcon.react
73
+ className="jp-xtralab-About-logo"
74
+ height="56px"
75
+ width="56px"
76
+ />
77
+ <div className="jp-xtralab-About-header-info">
78
+ <span className="jp-xtralab-About-wordmark">xtralab</span>
79
+ {version ? (
80
+ <span className="jp-xtralab-About-version">
81
+ {trans.__('Version %1', version)}
82
+ </span>
83
+ ) : null}
84
+ </div>
85
+ </span>
86
+ );
87
+
88
+ const body = (
89
+ <div className="jp-xtralab-About-body">
90
+ <span className="jp-xtralab-About-basedOn">
91
+ {trans.__('Based on JupyterLab %1', app.version)}
92
+ </span>
93
+ <span className="jp-xtralab-About-externalLinks">
94
+ <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
95
+ {trans.__('xtralab on GitHub')}
96
+ </a>
97
+ </span>
98
+ <span className="jp-xtralab-About-copyright">
99
+ {trans.__('© %1 Jeremy Tuloup', 2026)}
100
+ </span>
101
+ </div>
102
+ );
103
+
104
+ return showDialog({
105
+ title,
106
+ body,
107
+ buttons: [
108
+ Dialog.cancelButton({
109
+ label: trans.__('Close')
110
+ })
111
+ ]
112
+ });
113
+ }
114
+ });
115
+
116
+ if (palette) {
117
+ palette.addItem({
118
+ command: CommandIDs.about,
119
+ category: trans.__('Help')
120
+ });
121
+ }
122
+ }
123
+ };
124
+
125
+ export default plugin;
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
2
 
3
+ import aboutPlugin from './about';
3
4
  import agentSessionsPlugin from './agentSessions';
4
5
  import askAgentPlugin from './askAgent';
5
6
  import commandBarPlugin from './commandBar';
@@ -10,6 +11,7 @@ import editorIndentPlugin from './editorIndent';
10
11
  import gitPlugins from './git';
11
12
  import highlightPlugin from './highlight';
12
13
  import launcherPlugins from './launcher';
14
+ import menuBarPlugin from './menuBar';
13
15
  import menusPlugin from './menus';
14
16
  import omniboxPlugin from './omnibox';
15
17
  import searchReplacePlugin from './searchReplace';
@@ -30,6 +32,7 @@ import walkthroughPlugin from './walkthrough';
30
32
  * they are spread in.
31
33
  */
32
34
  const plugins: JupyterFrontEndPlugin<unknown>[] = [
35
+ aboutPlugin,
33
36
  agentSessionsPlugin,
34
37
  askAgentPlugin,
35
38
  commandBarPlugin,
@@ -40,6 +43,7 @@ const plugins: JupyterFrontEndPlugin<unknown>[] = [
40
43
  ...gitPlugins,
41
44
  highlightPlugin,
42
45
  ...launcherPlugins,
46
+ menuBarPlugin,
43
47
  menusPlugin,
44
48
  omniboxPlugin,
45
49
  searchReplacePlugin,
@@ -0,0 +1,14 @@
1
+ import { LabIcon } from '@jupyterlab/ui-components';
2
+
3
+ /**
4
+ * Hamburger mark for the collapsed main-menu button, matching the sidebar
5
+ * toggles in ../topBar/icons.ts.
6
+ */
7
+ export const mainMenuIcon = new LabIcon({
8
+ name: 'xtralab:main-menu',
9
+ svgstr: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
10
+ <line x1="4.5" y1="7" x2="19.5" y2="7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
11
+ <line x1="4.5" y1="12" x2="19.5" y2="12" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
12
+ <line x1="4.5" y1="17" x2="19.5" y2="17" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
13
+ </svg>`
14
+ });
@@ -0,0 +1,195 @@
1
+ import {
2
+ ILabShell,
3
+ JupyterFrontEnd,
4
+ JupyterFrontEndPlugin
5
+ } from '@jupyterlab/application';
6
+ import { ICommandPalette } from '@jupyterlab/apputils';
7
+ import { IMainMenu, MainMenu } from '@jupyterlab/mainmenu';
8
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
9
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
10
+ import { CommandToolbarButton, MenuSvg } from '@jupyterlab/ui-components';
11
+ import { Menu, Widget } from '@lumino/widgets';
12
+
13
+ import { mainMenuIcon } from './icons';
14
+
15
+ const PLUGIN_ID = 'xtralab:menu-bar';
16
+
17
+ const OPEN_COMMAND = 'xtralab:open-main-menu';
18
+ const TOGGLE_COMMAND = 'xtralab:toggle-menu-bar';
19
+
20
+ /**
21
+ * How long after the popup closes a button press still counts as "close
22
+ * only". An open Lumino menu closes itself on any outside press from a
23
+ * document `pointerdown` listener in the capture phase, which runs before
24
+ * the button's own mousedown handler — so without this window, pressing the
25
+ * button while the popup is open would close it and instantly reopen it.
26
+ */
27
+ const REOPEN_GUARD_MS = 250;
28
+
29
+ /**
30
+ * Collapse the main menu bar into a compact menu button.
31
+ *
32
+ * The menu bar is hidden and a hamburger button in the top bar opens the same
33
+ * menus as a vertical popup. The popup reuses the live `RankedMenu` instances
34
+ * owned by the `MainMenu`, so menus added or removed at runtime are reflected
35
+ * without duplication.
36
+ *
37
+ * Hiding covers the `jp-menu-panel` container too, which has its own
38
+ * `min-height` and would otherwise leave an empty strip. The "Show Menu Bar"
39
+ * toggle restores the classic bar.
40
+ */
41
+ const plugin: JupyterFrontEndPlugin<void> = {
42
+ id: PLUGIN_ID,
43
+ description: 'Collapse the main menu bar into a compact menu button.',
44
+ autoStart: true,
45
+ requires: [IMainMenu, ILabShell, ISettingRegistry],
46
+ optional: [ICommandPalette, ITranslator],
47
+ activate: (
48
+ app: JupyterFrontEnd,
49
+ mainMenu: IMainMenu,
50
+ labShell: ILabShell,
51
+ settingRegistry: ISettingRegistry,
52
+ palette: ICommandPalette | null,
53
+ translator: ITranslator | null
54
+ ): void => {
55
+ const { commands } = app;
56
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
57
+
58
+ if (!(mainMenu instanceof MainMenu)) {
59
+ return;
60
+ }
61
+
62
+ const menuPanel = (): Widget | null =>
63
+ mainMenu.parent ??
64
+ Array.from(labShell.widgets('top')).find(w => w.id === 'jp-menu-panel') ??
65
+ null;
66
+
67
+ let visible = false;
68
+ let button: CommandToolbarButton | null = null;
69
+ let settings: ISettingRegistry.ISettings | null = null;
70
+
71
+ const applyVisibility = (value: boolean): void => {
72
+ visible = value;
73
+ mainMenu.setHidden(!value);
74
+ menuPanel()?.setHidden(!value);
75
+ button?.setHidden(value);
76
+ commands.notifyCommandChanged(TOGGLE_COMMAND);
77
+ };
78
+
79
+ mainMenu.hide();
80
+ menuPanel()?.hide();
81
+
82
+ let popup: Menu | null = null;
83
+ let popupClosedAt = 0;
84
+
85
+ const openMenu = (): void => {
86
+ if (visible) {
87
+ // The classic bar is on screen; behave like keyboard menu
88
+ // activation instead of opening a redundant popup that would
89
+ // compete with the bar for the same Menu instances.
90
+ mainMenu.activeIndex = 0;
91
+ mainMenu.openActiveMenu();
92
+ return;
93
+ }
94
+ if (Date.now() - popupClosedAt < REOPEN_GUARD_MS) {
95
+ return;
96
+ }
97
+
98
+ // One popup instance, rebuilt from the bar's current menus on every
99
+ // open so runtime menu changes stay reflected. It is a plain Lumino
100
+ // Menu rather than a MenuSvg: MenuSvg.insertItem re-wraps each
101
+ // submenu's renderer and insertItem on every call, which would pile
102
+ // wrappers onto the shared menus across reopens. The svg renderer and
103
+ // themed-container class are applied directly instead, matching how
104
+ // the real menus are constructed in MainMenu.
105
+ if (popup === null) {
106
+ popup = new Menu({ commands, renderer: MenuSvg.defaultRenderer });
107
+ popup.addClass('jp-ThemedContainer');
108
+ popup.addClass('jp-xtralab-MainMenuPopup');
109
+ popup.aboutToClose.connect(() => {
110
+ popupClosedAt = Date.now();
111
+ });
112
+ } else {
113
+ popup.clearItems();
114
+ }
115
+
116
+ // Skip empty menus: MenuBar disables their headings, but submenu items
117
+ // have no such affordance and would open a blank panel.
118
+ for (const menu of mainMenu.menus) {
119
+ if (menu.items.length > 0) {
120
+ popup.addItem({ type: 'submenu', submenu: menu });
121
+ }
122
+ }
123
+
124
+ const anchor = button?.node.getBoundingClientRect();
125
+ // The 3px gap matches the detached-dropdown offset chrome.css gives
126
+ // the real menu bar's dropdowns (.lm-Menu.lm-MenuBar-menu).
127
+ popup.open(anchor?.left ?? 0, (anchor?.bottom ?? 0) + 3);
128
+ };
129
+
130
+ commands.addCommand(OPEN_COMMAND, {
131
+ label: trans.__('Open Menu'),
132
+ caption: trans.__('Open the main menu'),
133
+ icon: mainMenuIcon,
134
+ execute: openMenu
135
+ });
136
+
137
+ commands.addCommand(TOGGLE_COMMAND, {
138
+ label: trans.__('Show Menu Bar'),
139
+ caption: trans.__('Toggle the visibility of the main menu bar'),
140
+ isToggled: () => visible,
141
+ execute: async () => {
142
+ applyVisibility(!visible);
143
+ if (settings !== null) {
144
+ try {
145
+ await settings.set('visible', visible);
146
+ } catch (reason) {
147
+ console.error(
148
+ 'xtralab: failed to persist menu bar visibility',
149
+ reason
150
+ );
151
+ }
152
+ }
153
+ }
154
+ });
155
+
156
+ if (palette) {
157
+ const category = trans.__('Other');
158
+ palette.addItem({ command: OPEN_COMMAND, category });
159
+ palette.addItem({ command: TOGGLE_COMMAND, category });
160
+ }
161
+
162
+ Promise.all([settingRegistry.load(PLUGIN_ID), app.restored])
163
+ .then(([loaded]) => {
164
+ settings = loaded;
165
+
166
+ button = new CommandToolbarButton({
167
+ commands,
168
+ id: OPEN_COMMAND,
169
+ icon: mainMenuIcon,
170
+ label: '',
171
+ caption: trans.__('Menu'),
172
+ noFocusOnClick: true,
173
+ 'aria-haspopup': 'menu'
174
+ });
175
+ button.id = 'xtralab-main-menu-button';
176
+ button.addClass('jp-xtralab-TopBarButton');
177
+ labShell.add(button, 'top', { rank: 0 });
178
+
179
+ const update = (): void => {
180
+ applyVisibility(loaded.get('visible').composite as boolean);
181
+ };
182
+ update();
183
+ loaded.changed.connect(update);
184
+ })
185
+ .catch(reason => {
186
+ // Without settings the collapsed state cannot be managed (and the
187
+ // button was never added) — reveal the stock bar rather than leave
188
+ // the app with no reachable menus.
189
+ applyVisibility(true);
190
+ console.error(`Failed to load settings for ${PLUGIN_ID}`, reason);
191
+ });
192
+ }
193
+ };
194
+
195
+ export default plugin;