Repository: nebari-dev/jupyterlab-gallery

Issue:
Implement placing examples in the launcher
To make the examples visible to users potentially the best location would be a collapsible section in the `jupyterlab-new-launcher` (when installed).

- This could be done by expanding the `ILauncher.IItemOptions` to include a custom `render()` method, and creating a section for each non-standard category. This would also make the new `jupyterlab-new-launcher` work with juyptext
- Alternatively, `jupyterlab-new-launcher` could expose a custom `.addSection()` method accepting a section name, state and a `ReactWidget` - this might be better for our use case as we could customize it more.

Discussion:
github-actions[bot]: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/nebari-dev/jupyterlab-gallery/support-new-launcher?urlpath=lab) :point_left: Launch a Binder on branch _nebari-dev/jupyterlab-gallery/support-new-launcher_
krassowski: ![demo](https://github.com/nebari-dev/jupyterlab-gallery/assets/5832902/7b3f4bb9-5d4f-4350-a216-4d6163aaf8a9)


Patch:
diff --git a/jupyterlab_gallery/handlers.py b/jupyterlab_gallery/handlers.py
index c2b5144..d5f3761 100644
--- a/jupyterlab_gallery/handlers.py
+++ b/jupyterlab_gallery/handlers.py
@@ -110,7 +110,7 @@ class ExhibitsHandler(APIHandler):
                         prepare_exhibit(exhibit_config, exhibit_id=i)
                         for i, exhibit_config in enumerate(exhibits)
                     ],
-                    "api_version": "1.0",
+                    "apiVersion": "1.0",
                 }
             )
         )
diff --git a/package.json b/package.json
index d257582..44e0e6c 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
     },
     "devDependencies": {
         "@jupyterlab/builder": "^4.0.0",
+        "@jupyterlab/launcher": "^4.0.0",
         "@jupyterlab/testutils": "^4.0.0",
         "@types/jest": "^29.2.0",
         "@types/json-schema": "^7.0.11",
@@ -76,6 +77,7 @@
         "eslint-config-prettier": "^8.8.0",
         "eslint-plugin-prettier": "^5.0.0",
         "jest": "^29.2.0",
+        "jupyterlab-new-launcher": "^0.4.0",
         "mkdirp": "^1.0.3",
         "npm-run-all": "^4.1.5",
         "prettier": "^3.0.0",
diff --git a/src/gallery.tsx b/src/gallery.tsx
index 054a00f..f20060b 100644
--- a/src/gallery.tsx
+++ b/src/gallery.tsx
@@ -1,7 +1,8 @@
 import * as React from 'react';
 import { ReactWidget, showErrorMessage } from '@jupyterlab/apputils';
-import { Button } from '@jupyterlab/ui-components';
-import { IStream, Stream } from '@lumino/signaling';
+import { Button, UseSignal } from '@jupyterlab/ui-components';
+import { Contents } from '@jupyterlab/services';
+import { IStream, Stream, Signal } from '@lumino/signaling';
 import { TranslationBundle } from '@jupyterlab/translation';
 import { IExhibit } from './types';
 import { IExhibitReply } from './types';
@@ -16,8 +17,10 @@ export class GalleryWidget extends ReactWidget {
   constructor(options: {
     trans: TranslationBundle;
     openPath: (path: string) => void;
+    fileChanged: Contents.IManager['fileChanged'];
+    refreshFileBrowser: () => Promise<void>;
   }) {
-    const { trans } = options;
+    const { trans, fileChanged } = options;
     super();
     this._status = trans.__('Gallery loading...');
     this._actions = {
@@ -25,17 +28,35 @@ export class GalleryWidget extends ReactWidget {
         options.openPath(exhibit.localPath);
         // TODO: should it open the directory in the file browser?
         // should it also open a readme for this repository?
-        //options.
       },
       download: async (exhibit: IExhibit) => {
+        const done = new Promise<void>((resolve, reject) => {
+          this._stream.connect((_, e) => {
+            if (e.exhibit_id === exhibit.id) {
+              if (e.phase === 'finished') {
+                resolve();
+              } else if (e.phase === 'error') {
+                reject();
+              }
+            }
+          });
+        });
         await requestAPI('pull', {
           method: 'POST',
           body: JSON.stringify({ exhibit_id: exhibit.id })
         });
+        await done;
         await this._load();
+        await options.refreshFileBrowser();
       }
     };
-    eventStream(
+    // if user deletes a directory, reload the state
+    fileChanged.connect((_, args) => {
+      if (args.type === 'delete') {
+        this._load();
+      }
+    });
+    this._eventSource = eventStream(
       'pull',
       message => {
         this._stream.emit(message);
@@ -48,6 +69,13 @@ export class GalleryWidget extends ReactWidget {
     void this._load();
   }
 
+  dispose() {
+    super.dispose();
+    this._eventSource.close();
+  }
+
+  private _eventSource: EventSource;
+
   private async _load() {
     try {
       const data = await requestAPI<IExhibitReply>('exhibits');
@@ -61,6 +89,7 @@ export class GalleryWidget extends ReactWidget {
     } catch (reason) {
       this._status = `jupyterlab_gallery server failed:\n${reason}`;
     }
+    this.update();
   }
 
   get exhibits(): IExhibit[] | null {
@@ -69,21 +98,34 @@ export class GalleryWidget extends ReactWidget {
 
   set exhibits(value: IExhibit[] | null) {
     this._exhibits = value;
-    this.update();
+  }
+
+  update() {
+    super.update();
+    this._update.emit();
   }
 
   render(): JSX.Element {
-    if (this.exhibits) {
-      return (
-        <Gallery
-          exhibits={this.exhibits}
-          actions={this._actions}
-          progressStream={this._stream}
-        />
-      );
-    }
-    return <div className="jp-Gallery jp-mod-loading">{this._status}</div>;
+    return (
+      <UseSignal signal={this._update}>
+        {() => {
+          if (this.exhibits) {
+            return (
+              <Gallery
+                exhibits={this.exhibits}
+                actions={this._actions}
+                progressStream={this._stream}
+              />
+            );
+          }
+          return (
+            <div className="jp-Gallery jp-mod-loading">{this._status}</div>
+          );
+        }}
+      </UseSignal>
+    );
   }
+  private _update = new Signal<GalleryWidget, void>(this);
   private _exhibits: IExhibit[] | null = null;
   private _status: string;
   private _actions: IActions;
@@ -123,7 +165,6 @@ function Exhibit(props: {
       if (exhibitId !== exhibit.id) {
         return;
       }
-      console.log('matched stream', message);
       if (message.phase === 'error') {
         showErrorMessage(
           'Could not download',
diff --git a/src/handler.ts b/src/handler.ts
index 4c2b8c7..2677412 100644
--- a/src/handler.ts
+++ b/src/handler.ts
@@ -55,7 +55,7 @@ export function eventStream(
   endPoint = '',
   onStream: (message: IStreamMessage) => void,
   onError: (error: Event) => void
-) {
+): EventSource {
   const settings = ServerConnection.makeSettings();
   const requestUrl = URLExt.join(
     settings.baseUrl,
@@ -65,12 +65,10 @@ export function eventStream(
   const eventSource = new EventSource(requestUrl);
   eventSource.addEventListener('message', event => {
     const data = JSON.parse(event.data);
-    if (data.phase === 'finished' || data.phase === 'error') {
-      eventSource.close();
-    }
     onStream(data);
   });
   eventSource.addEventListener('error', error => {
     onError(error);
   });
+  return eventSource;
 }
diff --git a/src/index.ts b/src/index.ts
index 5a72380..241c6b3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,9 +6,16 @@ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
 import { ISettingRegistry } from '@jupyterlab/settingregistry';
 import { IFileBrowserCommands } from '@jupyterlab/filebrowser';
 
+import { ILauncher } from '@jupyterlab/launcher';
+import type { INewLauncher } from 'jupyterlab-new-launcher/lib/types';
+
 import { GalleryWidget } from './gallery';
 import { galleryIcon } from './icons';
 
+function isNewLauncher(launcher: ILauncher): launcher is INewLauncher {
+  return 'addSection' in launcher;
+}
+
 /**
  * Initialization data for the jupyterlab-gallery extension.
  */
@@ -18,12 +25,13 @@ const plugin: JupyterFrontEndPlugin<void> = {
     'A JupyterLab gallery extension for presenting and downloading examples from remote repositories',
   autoStart: true,
   requires: [ISettingRegistry],
-  optional: [IFileBrowserCommands, ITranslator],
+  optional: [IFileBrowserCommands, ITranslator, ILauncher],
   activate: async (
     app: JupyterFrontEnd,
     settingRegistry: ISettingRegistry,
     fileBrowserCommands: IFileBrowserCommands | null,
-    translator: ITranslator | null
+    translator: ITranslator | null,
+    launcher: ILauncher | null
   ) => {
     console.log('JupyterLab extension jupyterlab-gallery is activated!');
 
@@ -37,16 +45,34 @@ const plugin: JupyterFrontEndPlugin<void> = {
           throw Error('filebrowser not available');
         }
         app.commands.execute(fileBrowserCommands.openPath, { path });
+      },
+      fileChanged: app.serviceManager.contents.fileChanged,
+      refreshFileBrowser: () => {
+        return app.commands.execute('filebrowser:refresh');
       }
     });
 
-    // TODO: should we put it in the sidebar, or in the main area?
+    const title = trans.__('Gallery');
     // add the widget to sidebar before waiting for server reply to reduce UI jitter
-    widget.id = 'jupyterlab-gallery:sidebar';
-    widget.title.icon = galleryIcon;
-    widget.title.caption = trans.__('Gallery');
-    widget.show();
-    app.shell.add(widget, 'left', { rank: 850 });
+    if (launcher && isNewLauncher(launcher)) {
+      launcher.addSection({
+        title,
+        className: 'jp-Launcher-openExample',
+        icon: galleryIcon,
+        id: 'gallery',
+        rank: 2.5,
+        render: () => {
+          return widget.render();
+        }
+      });
+    } else {
+      // fallback to placing it in the sidebar if new launcher is not installed
+      widget.id = 'jupyterlab-gallery:sidebar';
+      widget.title.icon = galleryIcon;
+      widget.title.caption = title;
+      widget.show();
+      app.shell.add(widget, 'left', { rank: 850 });
+    }
 
     try {
       const settings = await settingRegistry.load(plugin.id);
diff --git a/style/base.css b/style/base.css
index 216a66f..201086a 100644
--- a/style/base.css
+++ b/style/base.css
@@ -22,3 +22,7 @@
 .jp-Exhibit-icon {
   max-width: 100%;
 }
+
+.jp-Launcher-openExample .jp-Gallery {
+  display: contents;
+}
diff --git a/yarn.lock b/yarn.lock
index 86e3ce7..9496dd8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2156,6 +2156,35 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/apputils@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "@jupyterlab/apputils@npm:4.3.1"
+  dependencies:
+    "@jupyterlab/coreutils": ^6.2.1
+    "@jupyterlab/observables": ^5.2.1
+    "@jupyterlab/rendermime-interfaces": ^3.10.1
+    "@jupyterlab/services": ^7.2.1
+    "@jupyterlab/settingregistry": ^4.2.1
+    "@jupyterlab/statedb": ^4.2.1
+    "@jupyterlab/statusbar": ^4.2.1
+    "@jupyterlab/translation": ^4.2.1
+    "@jupyterlab/ui-components": ^4.2.1
+    "@lumino/algorithm": ^2.0.1
+    "@lumino/commands": ^2.3.0
+    "@lumino/coreutils": ^2.1.2
+    "@lumino/disposable": ^2.1.2
+    "@lumino/domutils": ^2.0.1
+    "@lumino/messaging": ^2.0.1
+    "@lumino/signaling": ^2.1.2
+    "@lumino/virtualdom": ^2.0.1
+    "@lumino/widgets": ^2.3.2
+    "@types/react": ^18.0.26
+    react: ^18.2.0
+    sanitize-html: ~2.12.1
+  checksum: 380d9059dd14ee47bb50a821515e0b4a92a2b60b6fed2bf15fb73b9192a2e95d1e6c97337f11d0c26870dba2dc89ee19604f068483df505e78d798510a61bf01
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/attachments@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/attachments@npm:4.2.0"
@@ -2327,6 +2356,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/coreutils@npm:^6.2.1":
+  version: 6.2.1
+  resolution: "@jupyterlab/coreutils@npm:6.2.1"
+  dependencies:
+    "@lumino/coreutils": ^2.1.2
+    "@lumino/disposable": ^2.1.2
+    "@lumino/signaling": ^2.1.2
+    minimist: ~1.2.0
+    path-browserify: ^1.0.0
+    url-parse: ~1.5.4
+  checksum: c8167bd8d4472471297e5669d6b3ee7c9d5c1246e8413680713b15f8a81926d2c97bc6a3c0b26c16603b197b412e01b443cc74b02a3676adea5690aac41964be
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/docmanager@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/docmanager@npm:4.2.0"
@@ -2425,6 +2468,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/launcher@npm:^4.0.0":
+  version: 4.2.1
+  resolution: "@jupyterlab/launcher@npm:4.2.1"
+  dependencies:
+    "@jupyterlab/apputils": ^4.3.1
+    "@jupyterlab/translation": ^4.2.1
+    "@jupyterlab/ui-components": ^4.2.1
+    "@lumino/algorithm": ^2.0.1
+    "@lumino/commands": ^2.3.0
+    "@lumino/coreutils": ^2.1.2
+    "@lumino/disposable": ^2.1.2
+    "@lumino/properties": ^2.0.1
+    "@lumino/widgets": ^2.3.2
+    react: ^18.2.0
+  checksum: cb376579427da1e6be1f2d5aa159430df5c984421e61abe167244da384c8598bfe96523150e3a303a200e5e3a1bbb1c2e176d81aa0425ecb5cfda20764b604a8
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/lsp@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/lsp@npm:4.2.0"
@@ -2457,6 +2518,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/nbformat@npm:^4.2.1":
+  version: 4.2.1
+  resolution: "@jupyterlab/nbformat@npm:4.2.1"
+  dependencies:
+    "@lumino/coreutils": ^2.1.2
+  checksum: 192167e2a9019bf91e1e7088c9eaaae7b1037f5e7b5db15b97687b052323e6e75913b301ca7a9783d0e59aa36f18ddff90fc71a90a8153e0c89e32fd92b2519c
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/notebook@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/notebook@npm:4.2.0"
@@ -2508,6 +2578,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/observables@npm:^5.2.1":
+  version: 5.2.1
+  resolution: "@jupyterlab/observables@npm:5.2.1"
+  dependencies:
+    "@lumino/algorithm": ^2.0.1
+    "@lumino/coreutils": ^2.1.2
+    "@lumino/disposable": ^2.1.2
+    "@lumino/messaging": ^2.0.1
+    "@lumino/signaling": ^2.1.2
+  checksum: 3833d3ad0640a6160fdc5254ec08a600e628e235103e311ca8ee90ade11b73e045ab78b82282153da700f9ae796a99ef36da223baad6c21ad7af0ea84b9514b6
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/outputarea@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/outputarea@npm:4.2.0"
@@ -2540,6 +2623,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/rendermime-interfaces@npm:^3.10.1":
+  version: 3.10.1
+  resolution: "@jupyterlab/rendermime-interfaces@npm:3.10.1"
+  dependencies:
+    "@lumino/coreutils": ^1.11.0 || ^2.1.2
+    "@lumino/widgets": ^1.37.2 || ^2.3.2
+  checksum: 537fe7d96f8e157d89de0035149bf98bfaf1b9fde92d4f58c1e879ce87cab586311aa18dfb02a218bd24aa3cd1f24122e256a70cb2a0a437cc4fea1c9a3f2fa1
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/rendermime@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/rendermime@npm:4.2.0"
@@ -2579,6 +2672,25 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/services@npm:^7.2.1":
+  version: 7.2.1
+  resolution: "@jupyterlab/services@npm:7.2.1"
+  dependencies:
+    "@jupyter/ydoc": ^2.0.1
+    "@jupyterlab/coreutils": ^6.2.1
+    "@jupyterlab/nbformat": ^4.2.1
+    "@jupyterlab/settingregistry": ^4.2.1
+    "@jupyterlab/statedb": ^4.2.1
+    "@lumino/coreutils": ^2.1.2
+    "@lumino/disposable": ^2.1.2
+    "@lumino/polling": ^2.1.2
+    "@lumino/properties": ^2.0.1
+    "@lumino/signaling": ^2.1.2
+    ws: ^8.11.0
+  checksum: f07be2f3a174466c17ab5c22f8ef622fc623e8c61f2220b8bfb465a263971313cb9129e84bba32606e6ab7d1e0be3a9754b97f98e173e9c95eaf0b1c6cd8110a
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/settingregistry@npm:^4.0.0, @jupyterlab/settingregistry@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/settingregistry@npm:4.2.0"
@@ -2598,6 +2710,25 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/settingregistry@npm:^4.2.1":
+  version: 4.2.1
+  resolution: "@jupyterlab/settingregistry@npm:4.2.1"
+  dependencies:
+    "@jupyterlab/nbformat": ^4.2.1
+    "@jupyterlab/statedb": ^4.2.1
+    "@lumino/commands": ^2.3.0
+    "@lumino/coreutils": ^2.1.2
+    "@lumino/disposable": ^2.1.2
+    "@lumino/signaling": ^2.1.2
+    "@rjsf/utils": ^5.13.4
+    ajv: ^8.12.0
+    json5: ^2.2.3
+  peerDependencies:
+    react: ">=16"
+  checksum: 794e5ecde19a40e1b95c0d636eed7b56bbdc46857c8f3b4ef446c1bc90e8ea660c2ccf8f36a238bc312002f106a5a8522bb057742d9c0d674b2974ef21a786d7
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/statedb@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/statedb@npm:4.2.0"
@@ -2611,6 +2742,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/statedb@npm:^4.2.1":
+  version: 4.2.1
+  resolution: "@jupyterlab/statedb@npm:4.2.1"
+  dependencies:
+    "@lumino/commands": ^2.3.0
+    "@lumino/coreutils": ^2.1.2
+    "@lumino/disposable": ^2.1.2
+    "@lumino/properties": ^2.0.1
+    "@lumino/signaling": ^2.1.2
+  checksum: 51e07db85269883bcd58fc5ba890db122e260e8d1ce4046f0b188453726694c2d909f27ca069ee3cd6944a93d70fcb8360074f87cdb13d611af2e24f6b14af30
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/statusbar@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/statusbar@npm:4.2.0"
@@ -2627,6 +2771,22 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/statusbar@npm:^4.2.1":
+  version: 4.2.1
+  resolution: "@jupyterlab/statusbar@npm:4.2.1"
+  dependencies:
+    "@jupyterlab/ui-components": ^4.2.1
+    "@lumino/algorithm": ^2.0.1
+    "@lumino/coreutils": ^2.1.2
+    "@lumino/disposable": ^2.1.2
+    "@lumino/messaging": ^2.0.1
+    "@lumino/signaling": ^2.1.2
+    "@lumino/widgets": ^2.3.2
+    react: ^18.2.0
+  checksum: 65a0e4e0fa29ddd088d8dd2ee007a5166f783aa2852acd4217f2ed52fa04f492119c6e5b6e4f83884766fe7cfed3135ddd8c89b564ac3cc34ed6559457994885
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/testing@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/testing@npm:4.2.0"
@@ -2698,6 +2858,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/translation@npm:^4.2.1":
+  version: 4.2.1
+  resolution: "@jupyterlab/translation@npm:4.2.1"
+  dependencies:
+    "@jupyterlab/coreutils": ^6.2.1
+    "@jupyterlab/rendermime-interfaces": ^3.10.1
+    "@jupyterlab/services": ^7.2.1
+    "@jupyterlab/statedb": ^4.2.1
+    "@lumino/coreutils": ^2.1.2
+  checksum: 509c9fd8790f852faaa7f956c2ac660247a8d1610cb9f08fd5a357f784a7f32f838ac388a47626da66ee207769253d16ea72235d608112d560dbc10417d9b8e4
+  languageName: node
+  linkType: hard
+
 "@jupyterlab/ui-components@npm:^4.2.0":
   version: 4.2.0
   resolution: "@jupyterlab/ui-components@npm:4.2.0"
@@ -2729,6 +2902,37 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jupyterlab/ui-components@npm:^4.2.1":
+  version: 4.2.1
+  resolution: "@jupyterlab/ui-components@npm:4.2.1"
+  dependencies:
+    "@jupyter/react-components": ^0.15.3
+    "@jupyter/web-components": ^0.15.3
+    "@jupyterlab/coreutils": ^6.2.1
+    "@jupyterlab/observables": ^5.2.1
+    "@jupyterlab/rendermime-interfaces": ^3.10.1
+    "@jupyterlab/translation": ^4.2.1
+    "@lumino/algorithm": ^2.0.1
+    "@lumino/commands": ^2.3.0
+    "@lumino/coreutils": ^2.1.2
+    "@lumino/disposable": ^2.1.2
+    "@lumino/messaging": ^2.0.1
+    "@lumino/polling": ^2.1.2
+    "@lumino/properties": ^2.0.1
+    "@lumino/signaling": ^2.1.2
+    "@lumino/virtualdom": ^2.0.1
+    "@lumino/widgets": ^2.3.2
+    "@rjsf/core": ^5.13.4
+    "@rjsf/utils": ^5.13.4
+    react: ^18.2.0
+    react-dom: ^18.2.0
+    typestyle: ^2.0.4
+  peerDependencies:
+    react: ^18.2.0
+  checksum: 7032d7755a7b69e98acc6378d9dedcc56d016cd0d4d6091bda3593baf89876a5e00f84116ab2a5ab5cc68439e07c2194eb7d211b6b3cff0a03cdfd11b03951bd
+  languageName: node
+  linkType: hard
+
 "@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1":
   version: 1.2.1
   resolution: "@lezer/common@npm:1.2.1"
@@ -7246,6 +7450,7 @@ __metadata:
     "@jupyterlab/application": ^4.0.0
     "@jupyterlab/builder": ^4.0.0
     "@jupyterlab/coreutils": ^6.0.0
+    "@jupyterlab/launcher": ^4.0.0
     "@jupyterlab/services": ^7.0.0
     "@jupyterlab/settingregistry": ^4.0.0
     "@jupyterlab/testutils": ^4.0.0
@@ -7260,6 +7465,7 @@ __metadata:
     eslint-config-prettier: ^8.8.0
     eslint-plugin-prettier: ^5.0.0
     jest: ^29.2.0
+    jupyterlab-new-launcher: ^0.4.0
     mkdirp: ^1.0.3
     npm-run-all: ^4.1.5
     prettier: ^3.0.0
@@ -7276,6 +7482,17 @@ __metadata:
   languageName: unknown
   linkType: soft
 
+"jupyterlab-new-launcher@npm:^0.4.0":
+  version: 0.4.0
+  resolution: "jupyterlab-new-launcher@npm:0.4.0"
+  dependencies:
+    "@jupyterlab/application": ^4.0.0
+    "@jupyterlab/launcher": ^4.0.0
+    "@jupyterlab/settingregistry": ^4.0.0
+  checksum: a963895f4f651bb69cf5cc50b5f61810f6643920ae7faad2a6e0a971769b5804b2d1e778cda8a10610303ddb3d0dee382b2c1d158960e69f59d0ea122c191e1b
+  languageName: node
+  linkType: hard
+
 "keyv@npm:^4.5.3":
   version: 4.5.4
   resolution: "keyv@npm:4.5.4"


Test patch:
diff --git a/jupyterlab_gallery/tests/test_handlers.py b/jupyterlab_gallery/tests/test_handlers.py
index 4eb37d7..a62442f 100644
--- a/jupyterlab_gallery/tests/test_handlers.py
+++ b/jupyterlab_gallery/tests/test_handlers.py
@@ -6,4 +6,4 @@ async def test_exhibits(jp_fetch):
     assert response.code == 200
     payload = json.loads(response.body)
     assert payload["exhibits"]
-    assert payload["api_version"] == "1.0"
+    assert payload["apiVersion"] == "1.0"