xtralab 0.13.0 → 0.14.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.
@@ -51,11 +51,11 @@ export function computeSections(options) {
51
51
  * Run a command and record the use on success, so failed commands never enter
52
52
  * the recents list.
53
53
  */
54
- function executeCommand(commands, recents, id) {
54
+ function executeCommand(commands, recents, id, args) {
55
55
  void commands
56
- .execute(id)
56
+ .execute(id, args)
57
57
  .then(() => {
58
- recents === null || recents === void 0 ? void 0 : recents.touch('command', id);
58
+ recents === null || recents === void 0 ? void 0 : recents.touch('command', id, args);
59
59
  })
60
60
  .catch(reason => {
61
61
  console.error(`xtralab omnibox: command "${id}" failed`, reason);
@@ -80,16 +80,16 @@ function openFile(commands, recents, path) {
80
80
  * accessors can assume a context — e.g. an active notebook — the omnibox
81
81
  * doesn't provide).
82
82
  */
83
- function commandDisplay(commands, id) {
83
+ function commandDisplay(commands, id, args) {
84
84
  if (!commands.hasCommand(id)) {
85
85
  return null;
86
86
  }
87
87
  try {
88
- const label = commands.label(id);
89
- if (!label || !commands.isVisible(id)) {
88
+ const label = commands.label(id, args);
89
+ if (!label || !commands.isVisible(id, args)) {
90
90
  return null;
91
91
  }
92
- return { label, caption: commands.caption(id) };
92
+ return { label, caption: commands.caption(id, args) };
93
93
  }
94
94
  catch (_a) {
95
95
  return null;
@@ -103,6 +103,7 @@ function commandDisplay(commands, id) {
103
103
  * unavailable — file entries are shown as recorded).
104
104
  */
105
105
  function buildRecentItems(options, mode) {
106
+ var _a;
106
107
  const { commands, docRegistry, files, recents } = options;
107
108
  if (!recents) {
108
109
  return [];
@@ -112,18 +113,18 @@ function buildRecentItems(options, mode) {
112
113
  const items = [];
113
114
  for (const entry of recents.entries(kind)) {
114
115
  if (entry.kind === 'command') {
115
- const display = commandDisplay(commands, entry.id);
116
+ const display = commandDisplay(commands, entry.id, entry.args);
116
117
  if (!display) {
117
118
  continue;
118
119
  }
119
120
  items.push({
120
121
  kind: 'command',
121
- key: `recent:command:${entry.id}`,
122
+ key: `recent:command:${entry.id}:${JSON.stringify((_a = entry.args) !== null && _a !== void 0 ? _a : null)}`,
122
123
  label: display.label,
123
124
  matchIndices: [],
124
125
  caption: display.caption || undefined,
125
126
  execute: () => {
126
- executeCommand(commands, recents, entry.id);
127
+ executeCommand(commands, recents, entry.id, entry.args);
127
128
  }
128
129
  });
129
130
  }
@@ -145,11 +146,65 @@ function buildRecentItems(options, mode) {
145
146
  }
146
147
  return items;
147
148
  }
149
+ /**
150
+ * Match the query against the palette's items first — their labels carry each
151
+ * item's args (one "Use Theme: …" per theme, one row per font-size key), which
152
+ * a raw registry scan can never produce — then against registry commands the
153
+ * palette doesn't present. Palette-covered ids are skipped in the registry
154
+ * pass so a command never also surfaces under its argless label.
155
+ */
148
156
  function matchCommands(options, term) {
149
- const { commands, recents } = options;
157
+ const { commands, paletteItems, recents } = options;
150
158
  const query = term.toLowerCase();
151
159
  const scored = [];
160
+ const inPalette = new Set();
161
+ const seenKeys = new Set();
162
+ for (const item of paletteItems) {
163
+ inPalette.add(item.command);
164
+ const { command: id, args } = item;
165
+ let label = '';
166
+ let visible = true;
167
+ let caption = '';
168
+ try {
169
+ // An item's accessors can throw if the command assumes a context (e.g.
170
+ // an active notebook) the omnibox doesn't provide; skip those.
171
+ label = item.label;
172
+ visible = item.isVisible;
173
+ caption = item.caption;
174
+ }
175
+ catch (_a) {
176
+ continue;
177
+ }
178
+ if (!label || !visible) {
179
+ continue;
180
+ }
181
+ const key = `command:${id}:${JSON.stringify(args)}`;
182
+ if (seenKeys.has(key)) {
183
+ continue;
184
+ }
185
+ seenKeys.add(key);
186
+ const match = StringExt.matchSumOfSquares(label.toLowerCase(), query);
187
+ if (!match) {
188
+ continue;
189
+ }
190
+ scored.push({
191
+ score: match.score,
192
+ item: {
193
+ kind: 'command',
194
+ key,
195
+ label,
196
+ matchIndices: match.indices,
197
+ caption: caption || undefined,
198
+ execute: () => {
199
+ executeCommand(commands, recents, id, args);
200
+ }
201
+ }
202
+ });
203
+ }
152
204
  for (const id of commands.listCommands()) {
205
+ if (inPalette.has(id)) {
206
+ continue;
207
+ }
153
208
  let label = '';
154
209
  let visible = true;
155
210
  let caption = '';
@@ -160,7 +215,7 @@ function matchCommands(options, term) {
160
215
  visible = commands.isVisible(id);
161
216
  caption = commands.caption(id);
162
217
  }
163
- catch (_a) {
218
+ catch (_b) {
164
219
  continue;
165
220
  }
166
221
  if (!label || !visible) {
@@ -1,4 +1,5 @@
1
1
  import type { IStateDB } from '@jupyterlab/statedb';
2
+ import type { ReadonlyPartialJSONObject } from '@lumino/coreutils';
2
3
  /**
3
4
  * Kinds of omnibox results whose uses are recorded.
4
5
  */
@@ -12,6 +13,11 @@ interface IRecentEntry {
12
13
  * The command id, or the workspace-relative file path.
13
14
  */
14
15
  id: string;
16
+ /**
17
+ * Arguments the command ran with, for palette-style entries whose label
18
+ * and behavior depend on them (e.g. one "Use Theme: …" per theme).
19
+ */
20
+ args?: ReadonlyPartialJSONObject;
15
21
  }
16
22
  /**
17
23
  * A most-recently-used list of omnibox results, generic over the result kind
@@ -34,9 +40,10 @@ export declare class OmniboxRecents {
34
40
  */
35
41
  entries(kind?: RecentKind): IRecentEntry[];
36
42
  /**
37
- * Record a use of `id`, moving it to the front of its kind's list.
43
+ * Record a use of `id` (with the args it ran with, if any), moving it to
44
+ * the front of its kind's list.
38
45
  */
39
- touch(kind: RecentKind, id: string): void;
46
+ touch(kind: RecentKind, id: string, args?: ReadonlyPartialJSONObject): void;
40
47
  /**
41
48
  * Load the persisted list. Entries recorded before the load completes stay
42
49
  * at the front, ahead of the restored history.
@@ -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.0",
3
+ "version": "0.14.0",
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,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;
@@ -6,6 +6,7 @@ import type { CommandRegistry } from '@lumino/commands';
6
6
  import { Widget } from '@lumino/widgets';
7
7
 
8
8
  const EDITOR_BREADCRUMBS_CLASS = 'jp-xtralab-EditorBreadcrumbs';
9
+ const TOOLBAR_SPACER_CLASS = 'jp-Toolbar-spacer';
9
10
  const BREADCRUMBS_CONTAINER_CLASS = 'jp-BreadCrumbs-container';
10
11
  const BREADCRUMBS_CONTENT_CLASS = 'jp-BreadCrumbs-content';
11
12
  const ROOT_CLASS = 'jp-BreadCrumbs-home';
@@ -37,6 +38,10 @@ export class EditorBreadcrumbs extends Widget {
37
38
  this._commands = options.commands;
38
39
  this._trans = options.trans;
39
40
  this.addClass(EDITOR_BREADCRUMBS_CLASS);
41
+ // The breadcrumbs stretch to fill the toolbar, so ReactiveToolbar's
42
+ // overflow math must count them as a spacer (2px) — measured at their
43
+ // stretched clientWidth they would evict every item into the popup.
44
+ this.addClass(TOOLBAR_SPACER_CLASS);
40
45
  this.node.setAttribute('aria-label', this._trans.__('Editor file path'));
41
46
 
42
47
  this._container = document.createElement('span');
@@ -19,6 +19,7 @@ import {
19
19
  downloadIcon,
20
20
  editIcon,
21
21
  fileIcon,
22
+ filterIcon,
22
23
  IDisposableMenuItem,
23
24
  newFolderIcon,
24
25
  RankedMenu,
@@ -47,6 +48,7 @@ export namespace CommandIDs {
47
48
  export const download = 'xtralab:download';
48
49
  export const refresh = 'xtralab:refresh';
49
50
  export const collapseAll = 'xtralab:collapse-all';
51
+ export const toggleFileFilter = 'xtralab:toggle-file-filter';
50
52
  export const createNewDirectory = 'xtralab:create-new-directory';
51
53
  export const newLauncher = 'xtralab:new-launcher';
52
54
  export const revealPath = 'xtralab:reveal-path';
@@ -484,6 +486,24 @@ export function registerCommands(opts: IRegisterCommandsOptions): () => void {
484
486
  }
485
487
  });
486
488
 
489
+ commands.addCommand(CommandIDs.toggleFileFilter, {
490
+ label: trans.__('Toggle File Filter'),
491
+ caption: trans.__('Show or hide the file filter'),
492
+ icon: filterIcon.bindprops({ stylesheet: 'menuItem' }),
493
+ isToggled: () => browser.fileFilterVisible,
494
+ execute: () => {
495
+ browser.toggleFileFilter();
496
+ }
497
+ });
498
+
499
+ // Refresh the toggle's state however the filter is shown or hidden —
500
+ // the toolbar button, or the auto-show that kicks in when typing with
501
+ // the tree focused opens a search session.
502
+ const onFilterVisibleChanged = (): void => {
503
+ commands.notifyCommandChanged(CommandIDs.toggleFileFilter);
504
+ };
505
+ browser.fileFilterVisibleChanged.connect(onFilterVisibleChanged);
506
+
487
507
  // Public reveal seam. Other plugins (editor breadcrumbs, future
488
508
  // "reveal in tree" actions) call this command rather than depending
489
509
  // on the file browser widget directly. Activating the sidebar is part
@@ -591,6 +611,7 @@ export function registerCommands(opts: IRegisterCommandsOptions): () => void {
591
611
  return () => {
592
612
  app.contextMenu.opened.disconnect(updateOpenWithMenu);
593
613
  app.shell.currentChanged?.disconnect(onCurrentChanged);
614
+ browser.fileFilterVisibleChanged.disconnect(onFilterVisibleChanged);
594
615
  paletteItem?.dispose();
595
616
  };
596
617
  }
@@ -605,13 +626,14 @@ export namespace ToolbarNames {
605
626
  export const newDirectory = 'new-directory';
606
627
  export const refresh = 'refresh';
607
628
  export const collapseAll = 'collapse-all';
629
+ export const toggleFileFilter = 'toggle-file-filter';
608
630
  }
609
631
 
610
632
  /**
611
633
  * Populate the file browser's toolbar with the buttons that mirror the
612
634
  * default JupyterLab file browser: a "+" launcher button, a new-folder
613
- * button, and a refresh button. Returns a list of button names so callers
614
- * can extend it or remove items if they need to.
635
+ * button, a refresh button, a collapse-all button, and the file filter
636
+ * toggle.
615
637
  */
616
638
  export function populateToolbar(opts: {
617
639
  app: JupyterFrontEnd;
@@ -652,4 +674,12 @@ export function populateToolbar(opts: {
652
674
  label: ''
653
675
  })
654
676
  );
677
+ browser.toolbar.addItem(
678
+ ToolbarNames.toggleFileFilter,
679
+ new CommandToolbarButton({
680
+ commands,
681
+ id: CommandIDs.toggleFileFilter,
682
+ label: ''
683
+ })
684
+ );
655
685
  }