@jupyterlab/notebook-extension 4.7.0-alpha.0 → 4.7.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import { ILabShell, ILayoutRestorer, IRouter } from '@jupyterlab/application';
16
16
  import type { ISessionContext } from '@jupyterlab/apputils';
17
17
  import {
18
18
  Clipboard,
19
+ CommandLinker,
19
20
  createToolbarFactory,
20
21
  Dialog,
21
22
  ICommandPalette,
@@ -135,7 +136,7 @@ import type {
135
136
  ReadonlyJSONValue,
136
137
  ReadonlyPartialJSONObject
137
138
  } from '@lumino/coreutils';
138
- import { JSONExt, UUID } from '@lumino/coreutils';
139
+ import { JSONExt, Token, UUID } from '@lumino/coreutils';
139
140
  import type { IDisposable } from '@lumino/disposable';
140
141
  import { DisposableSet } from '@lumino/disposable';
141
142
  import type { Message } from '@lumino/messaging';
@@ -145,7 +146,10 @@ import { Panel } from '@lumino/widgets';
145
146
  import { CellBarExtension } from '@jupyterlab/cell-toolbar';
146
147
  import { cellExecutor } from './cellexecutor';
147
148
  import { logNotebookOutput } from './nboutput';
148
- import { ActiveCellTool } from './tool-widgets/activeCellToolWidget';
149
+ import {
150
+ ActiveCellTool,
151
+ CellIdField
152
+ } from './tool-widgets/activeCellToolWidget';
149
153
  import {
150
154
  CellMetadataField,
151
155
  NotebookMetadataField
@@ -189,6 +193,8 @@ namespace CommandIDs {
189
193
 
190
194
  export const exportToFormat = 'notebook:export-to-format';
191
195
 
196
+ export const showExportGuidance = 'notebook:show-export-guidance';
197
+
192
198
  export const run = 'notebook:run-cell';
193
199
 
194
200
  export const runAndAdvance = 'notebook:run-cell-and-select-next';
@@ -372,6 +378,30 @@ const FACTORY = 'Notebook';
372
378
  */
373
379
  const FORMAT_EXCLUDE = ['notebook', 'python', 'custom'];
374
380
 
381
+ /**
382
+ * Documentation page describing notebook export support.
383
+ */
384
+ const NOTEBOOK_EXPORT_DOCS_URL =
385
+ 'https://jupyterlab.readthedocs.io/en/stable/user/export.html';
386
+
387
+ /**
388
+ * An interface describing how export guidance is presented to users.
389
+ */
390
+ export interface INotebookExportGuidance {
391
+ /**
392
+ * Show guidance for enabling notebook exports.
393
+ */
394
+ showExportHelp(): Promise<void>;
395
+ }
396
+
397
+ /**
398
+ * A token describing a service for showing notebook export guidance.
399
+ */
400
+ export const INotebookExportGuidance = new Token<INotebookExportGuidance>(
401
+ '@jupyterlab/notebook-extension:INotebookExportGuidance',
402
+ 'A service for showing notebook export guidance.'
403
+ );
404
+
375
405
  /**
376
406
  * Setting Id storing the customized toolbar definition.
377
407
  */
@@ -648,13 +678,14 @@ export const exportPlugin: JupyterFrontEndPlugin<void> = {
648
678
  description: 'Adds the export notebook commands.',
649
679
  autoStart: true,
650
680
  requires: [ITranslator, INotebookTracker],
651
- optional: [IMainMenu, ICommandPalette],
681
+ optional: [IMainMenu, ICommandPalette, INotebookExportGuidance],
652
682
  activate: (
653
683
  app: JupyterFrontEnd,
654
684
  translator: ITranslator,
655
685
  tracker: INotebookTracker,
656
686
  mainMenu: IMainMenu | null,
657
- palette: ICommandPalette | null
687
+ palette: ICommandPalette | null,
688
+ exportGuidance: INotebookExportGuidance | null
658
689
  ) => {
659
690
  const trans = translator.load('jupyterlab');
660
691
  const { commands, shell } = app;
@@ -745,21 +776,91 @@ export const exportPlugin: JupyterFrontEndPlugin<void> = {
745
776
  }
746
777
  });
747
778
 
779
+ commands.addCommand(CommandIDs.showExportGuidance, {
780
+ label: trans.__('Enable notebook exports'),
781
+ execute: () => {
782
+ if (exportGuidance) {
783
+ return exportGuidance.showExportHelp();
784
+ }
785
+ window.open(NOTEBOOK_EXPORT_DOCS_URL, '_blank', 'noopener,noreferrer');
786
+ return undefined;
787
+ },
788
+ describedBy: {
789
+ args: {
790
+ type: 'object',
791
+ properties: {}
792
+ }
793
+ }
794
+ });
795
+
748
796
  // Add a notebook group to the File menu.
749
- let exportTo: Menu | null | undefined;
750
- if (mainMenu) {
751
- exportTo = mainMenu.fileMenu.items.find(
752
- item =>
753
- item.type === 'submenu' &&
754
- item.submenu?.id === 'jp-mainmenu-file-notebookexport'
755
- )?.submenu;
756
- }
797
+ const fileMenu = mainMenu?.fileMenu as Menu | undefined;
798
+
799
+ const getExportMenu = (): Menu | undefined => {
800
+ return (
801
+ fileMenu?.items.find(
802
+ item =>
803
+ item.type === 'submenu' &&
804
+ item.submenu?.id === 'jp-mainmenu-file-notebookexport'
805
+ )?.submenu ?? undefined
806
+ );
807
+ };
757
808
 
758
809
  let formatsInitialized = false;
810
+ let paletteInitialized = false;
811
+ let exportFormats: Array<{ format: string; label: string }> | null = null;
812
+
813
+ const updateExportMenu = () => {
814
+ const exportTo = getExportMenu();
815
+ if (!exportTo || !exportFormats) {
816
+ return;
817
+ }
818
+
819
+ exportTo.clearItems();
820
+
821
+ if (exportFormats.length === 0) {
822
+ exportTo.addItem({
823
+ command: CommandIDs.showExportGuidance
824
+ });
825
+ return;
826
+ }
827
+
828
+ exportFormats.forEach(({ format, label }) => {
829
+ exportTo.addItem({
830
+ command: CommandIDs.exportToFormat,
831
+ args: {
832
+ format,
833
+ label,
834
+ isPalette: false
835
+ }
836
+ });
837
+ });
838
+
839
+ if (palette && !paletteInitialized) {
840
+ const category = trans.__('Notebook Operations');
841
+ exportFormats.forEach(({ format, label }) => {
842
+ palette.addItem({
843
+ command: CommandIDs.exportToFormat,
844
+ category,
845
+ args: {
846
+ format,
847
+ label,
848
+ isPalette: true
849
+ }
850
+ });
851
+ });
852
+ paletteInitialized = true;
853
+ }
854
+ };
759
855
 
760
856
  /** Request formats only when a notebook might use them. */
761
857
  const maybeInitializeFormats = async () => {
762
858
  if (formatsInitialized) {
859
+ updateExportMenu();
860
+ return;
861
+ }
862
+
863
+ if (tracker.size === 0) {
763
864
  return;
764
865
  }
765
866
 
@@ -767,53 +868,99 @@ export const exportPlugin: JupyterFrontEndPlugin<void> = {
767
868
 
768
869
  formatsInitialized = true;
769
870
 
770
- const response = await services.nbconvert.getExportFormats(false);
771
-
772
- if (!response) {
773
- return;
871
+ let response: NbConvert.IExportFormats | null = null;
872
+ try {
873
+ response = await services.nbconvert.getExportFormats(false);
874
+ } catch {
875
+ // Ignore fetch errors and fallback to export guidance.
774
876
  }
775
877
 
776
- const formatLabels: any = Private.getFormatLabels(translator);
878
+ const formatLabels = Private.getFormatLabels(translator);
777
879
 
778
- // Convert export list to palette and menu items.
779
- const formatList = Object.keys(response);
780
- formatList.forEach(function (key) {
781
- const formattedKey = key[0].toLocaleUpperCase() + key.slice(1);
782
- const capCaseKey = trans.__(formattedKey);
783
- const labelStr = formatLabels[key] ? formatLabels[key] : capCaseKey;
784
- let args = {
785
- format: key,
786
- label: labelStr,
787
- isPalette: false
788
- };
789
- if (FORMAT_EXCLUDE.indexOf(key) === -1) {
790
- if (exportTo) {
791
- exportTo.addItem({
792
- command: CommandIDs.exportToFormat,
793
- args: args
794
- });
795
- }
796
- if (palette) {
797
- args = {
798
- format: key,
799
- label: labelStr,
800
- isPalette: true
801
- };
802
- const category = trans.__('Notebook Operations');
803
- palette.addItem({
804
- command: CommandIDs.exportToFormat,
805
- category,
806
- args
807
- });
808
- }
809
- }
810
- });
880
+ // Convert export list to menu and palette items.
881
+ exportFormats = Object.keys(response ?? {})
882
+ .filter(key => !FORMAT_EXCLUDE.includes(key))
883
+ .map(key => {
884
+ const formattedKey = key[0].toLocaleUpperCase() + key.slice(1);
885
+ // Fallback for export formats the server offers but `formatLabels`
886
+ // does not know about, so there is no literal to extract.
887
+ // eslint-disable-next-line jupyter/no-dynamic-translation
888
+ const capCaseKey = trans.__(formattedKey);
889
+ const labelStr = formatLabels[key] ? formatLabels[key] : capCaseKey;
890
+ return {
891
+ format: key,
892
+ label: labelStr
893
+ };
894
+ });
895
+
896
+ updateExportMenu();
811
897
  };
812
898
 
813
899
  tracker.widgetAdded.connect(maybeInitializeFormats);
900
+ if (tracker.size > 0) {
901
+ void maybeInitializeFormats();
902
+ }
903
+ void app.restored.then(() => {
904
+ void maybeInitializeFormats();
905
+ });
814
906
  }
815
907
  };
816
908
 
909
+ /**
910
+ * A plugin providing the default notebook export guidance service.
911
+ */
912
+ export const exportGuidanceDialogPlugin: JupyterFrontEndPlugin<INotebookExportGuidance> =
913
+ {
914
+ id: '@jupyterlab/notebook-extension:export-guidance-dialog',
915
+ description:
916
+ 'Provides the default notebook export guidance shown when no exporters are available.',
917
+ provides: INotebookExportGuidance,
918
+ autoStart: true,
919
+ requires: [ITranslator],
920
+ activate: (
921
+ app: JupyterFrontEnd,
922
+ translator: ITranslator
923
+ ): INotebookExportGuidance => {
924
+ const trans = translator.load('jupyterlab');
925
+ const { commands } = app;
926
+
927
+ const openExportDocs = (url: string, docsLabel: string) => {
928
+ if (commands.hasCommand('help:open')) {
929
+ return commands.execute('help:open', {
930
+ url,
931
+ text: docsLabel,
932
+ newBrowserTab: true
933
+ });
934
+ }
935
+
936
+ window.open(url, '_blank', 'noopener,noreferrer');
937
+ return undefined;
938
+ };
939
+
940
+ return {
941
+ showExportHelp: async () => {
942
+ const result = await showDialog({
943
+ title: trans.__('Notebook exports are unavailable'),
944
+ body: trans.__(
945
+ 'No notebook export formats are currently available. To enable exports, install nbconvert in the server environment or use another exporter supported by your deployment.'
946
+ ),
947
+ buttons: [
948
+ Dialog.cancelButton({ label: trans.__('Close') }),
949
+ Dialog.okButton({ label: trans.__('Open Documentation') })
950
+ ]
951
+ });
952
+
953
+ if (result.button.accept) {
954
+ void openExportDocs(
955
+ NOTEBOOK_EXPORT_DOCS_URL,
956
+ trans.__('Notebook Export Documentation')
957
+ );
958
+ }
959
+ }
960
+ };
961
+ }
962
+ };
963
+
817
964
  /**
818
965
  * A plugin that adds a notebook trust status item to the status bar.
819
966
  */
@@ -1139,6 +1286,9 @@ const updateRawMimetype: JupyterFrontEndPlugin<void> = {
1139
1286
  ).length > 0;
1140
1287
  if (!mimetypeExists) {
1141
1288
  const formattedKey = key[0].toLocaleUpperCase() + key.slice(1);
1289
+ // Fallback for export formats the server offers but `formatLabels`
1290
+ // does not know about, so there is no literal to extract.
1291
+ // eslint-disable-next-line jupyter/no-dynamic-translation
1142
1292
  const altOption = trans.__(formattedKey);
1143
1293
  const option = formatLabels[key] ? formatLabels[key] : altOption;
1144
1294
  const mimeTypeValue = response[key].output_mimetype;
@@ -1176,15 +1326,20 @@ const customMetadataEditorFields: JupyterFrontEndPlugin<void> = {
1176
1326
  ) => {
1177
1327
  const editorFactory: CodeEditor.Factory = options =>
1178
1328
  editorServices.factoryService.newInlineEditor(options);
1179
- // Register the custom fields.
1329
+ // Register the custom fields. As with the active cell tool below, the
1330
+ // field renderers are used by rjsf as React components, so they run on
1331
+ // every rebuild of the metadata form. Each field is created once and
1332
+ // reused: constructing one per render abandons an editor, its host node
1333
+ // and its listeners on every keystroke that rebuilds the form.
1334
+ const cellMetadataField = new CellMetadataField({
1335
+ editorFactory,
1336
+ tracker,
1337
+ label: 'Cell metadata',
1338
+ translator: translator
1339
+ });
1180
1340
  const cellComponent: IFormRenderer = {
1181
1341
  fieldRenderer: (props: FieldProps) => {
1182
- return new CellMetadataField({
1183
- editorFactory,
1184
- tracker,
1185
- label: 'Cell metadata',
1186
- translator: translator
1187
- }).render(props);
1342
+ return cellMetadataField.render(props);
1188
1343
  }
1189
1344
  };
1190
1345
  formRegistry.addRenderer(
@@ -1192,14 +1347,15 @@ const customMetadataEditorFields: JupyterFrontEndPlugin<void> = {
1192
1347
  cellComponent
1193
1348
  );
1194
1349
 
1350
+ const notebookMetadataField = new NotebookMetadataField({
1351
+ editorFactory,
1352
+ tracker,
1353
+ label: 'Notebook metadata',
1354
+ translator: translator
1355
+ });
1195
1356
  const notebookComponent: IFormRenderer = {
1196
1357
  fieldRenderer: (props: FieldProps) => {
1197
- return new NotebookMetadataField({
1198
- editorFactory,
1199
- tracker,
1200
- label: 'Notebook metadata',
1201
- translator: translator
1202
- }).render(props);
1358
+ return notebookMetadataField.render(props);
1203
1359
  }
1204
1360
  };
1205
1361
  formRegistry.addRenderer(
@@ -1214,28 +1370,50 @@ const customMetadataEditorFields: JupyterFrontEndPlugin<void> = {
1214
1370
  */
1215
1371
  const activeCellTool: JupyterFrontEndPlugin<void> = {
1216
1372
  id: '@jupyterlab/notebook-extension:active-cell-tool',
1217
- description: 'Adds active cell field in the metadata editor tab.',
1373
+ description: 'Adds active cell fields in the metadata editor tab.',
1218
1374
  autoStart: true,
1219
1375
  requires: [INotebookTracker, IFormRendererRegistry, IEditorLanguageRegistry],
1376
+ optional: [ITranslator],
1220
1377
  activate: (
1221
1378
  // Register the custom field.
1222
1379
  app: JupyterFrontEnd,
1223
1380
  tracker: INotebookTracker,
1224
1381
  formRegistry: IFormRendererRegistry,
1225
- languages: IEditorLanguageRegistry
1382
+ languages: IEditorLanguageRegistry,
1383
+ translator?: ITranslator
1226
1384
  ) => {
1385
+ // The field renderer is used by rjsf as a React component, so it runs on
1386
+ // every rebuild of the metadata form. The tool is created once and reused:
1387
+ // constructing one per render would rebuild the prompt and the preview from
1388
+ // scratch (showing them empty until the next update) and would leave a
1389
+ // connection to the cell model behind for every abandoned instance.
1390
+ const tool = new ActiveCellTool({
1391
+ tracker,
1392
+ languages
1393
+ });
1227
1394
  const component: IFormRenderer = {
1228
1395
  fieldRenderer: (props: FieldProps) => {
1229
- return new ActiveCellTool({
1230
- tracker,
1231
- languages
1232
- }).render(props);
1396
+ return tool.render(props);
1233
1397
  }
1234
1398
  };
1235
1399
  formRegistry.addRenderer(
1236
1400
  '@jupyterlab/notebook-extension:active-cell-tool.renderer',
1237
1401
  component
1238
1402
  );
1403
+
1404
+ const cellIdComponent: IFormRenderer = {
1405
+ fieldRenderer: (props: FieldProps) => {
1406
+ return CellIdField({
1407
+ ...props,
1408
+ tracker,
1409
+ translator
1410
+ });
1411
+ }
1412
+ };
1413
+ formRegistry.addRenderer(
1414
+ '@jupyterlab/notebook-extension:active-cell-tool.cell-id',
1415
+ cellIdComponent
1416
+ );
1239
1417
  }
1240
1418
  };
1241
1419
 
@@ -1315,6 +1493,7 @@ const plugins: JupyterFrontEndPlugin<any>[] = [
1315
1493
  trackerPlugin,
1316
1494
  pageHandlerPlugin,
1317
1495
  executionIndicator,
1496
+ exportGuidanceDialogPlugin,
1318
1497
  exportPlugin,
1319
1498
  tools,
1320
1499
  cellCounterItem,
@@ -1436,7 +1615,8 @@ function activatePageHandler(
1436
1615
 
1437
1616
  const mimeData = data as nbformat.IMimeBundle;
1438
1617
  const metadata = (payload['metadata'] ?? {}) as ReadonlyJSONObject;
1439
- const mimeType = rendermime.preferredMimeType(mimeData, 'any');
1618
+ const trusted = false;
1619
+ const mimeType = rendermime.preferredMimeType(mimeData, 'ensure');
1440
1620
  if (!mimeType) {
1441
1621
  return false;
1442
1622
  }
@@ -1456,7 +1636,7 @@ function activatePageHandler(
1456
1636
 
1457
1637
  const renderer = rendermime.createRenderer(mimeType);
1458
1638
  void renderer.renderModel(
1459
- new MimeModel({ data: mimeData, metadata, trusted: true })
1639
+ new MimeModel({ data: mimeData, metadata, trusted })
1460
1640
  );
1461
1641
  content.addWidget(renderer);
1462
1642
 
@@ -2132,8 +2312,12 @@ function activateNotebookHandler(
2132
2312
  value: settings.get('sideBySideOutputRatio').composite as number
2133
2313
  })
2134
2314
  .then(result => {
2135
- setSideBySideOutputRatio(result.value!);
2136
- if (result.value) {
2315
+ if (
2316
+ result.value !== null &&
2317
+ Number.isFinite(result.value) &&
2318
+ result.value >= 0
2319
+ ) {
2320
+ setSideBySideOutputRatio(result.value);
2137
2321
  void settings.set('sideBySideOutputRatio', result.value);
2138
2322
  }
2139
2323
  })
@@ -2224,10 +2408,13 @@ function activateNotebookHandler(
2224
2408
  widget.title.iconLabel = ft?.iconLabel ?? '';
2225
2409
  widget.content.scrollbar = factory.notebookConfig.showMinimap ?? false;
2226
2410
 
2227
- // Notify the widget tracker if restore data needs to update.
2411
+ // Notify the widget tracker if restore data needs to update. The context
2412
+ // outlives this panel when other views of the document stay open, so the
2413
+ // connection is made with the panel as receiver: `Widget.dispose()` calls
2414
+ // `Signal.clearData(this)`, which removes it when the panel is closed.
2228
2415
  widget.context.pathChanged.connect(() => {
2229
2416
  void tracker.save(widget);
2230
- });
2417
+ }, widget);
2231
2418
  // Add the notebook panel to the tracker.
2232
2419
  void tracker.add(widget);
2233
2420
  });
@@ -2306,15 +2493,16 @@ function activateNotebookHandler(
2306
2493
  setSideBySideOutputRatio(factory.notebookConfig.sideBySideOutputRatio);
2307
2494
  const sideBySideMarginStyle = `.jp-mod-sideBySide.jp-Notebook .jp-Notebook-cell {
2308
2495
  margin-left: ${factory.notebookConfig.sideBySideLeftMarginOverride} !important;
2309
- margin-right: ${factory.notebookConfig.sideBySideRightMarginOverride} !important;`;
2496
+ margin-right: ${factory.notebookConfig.sideBySideRightMarginOverride} !important;
2497
+ }`;
2310
2498
  const sideBySideMarginTag = document.getElementById(SIDE_BY_SIDE_STYLE_ID);
2311
2499
  if (sideBySideMarginTag) {
2312
- sideBySideMarginTag.innerText = sideBySideMarginStyle;
2500
+ sideBySideMarginTag.textContent = sideBySideMarginStyle;
2313
2501
  } else {
2314
- document.head.insertAdjacentHTML(
2315
- 'beforeend',
2316
- `<style id="${SIDE_BY_SIDE_STYLE_ID}">${sideBySideMarginStyle}}</style>`
2317
- );
2502
+ const style = document.createElement('style');
2503
+ style.id = SIDE_BY_SIDE_STYLE_ID;
2504
+ style.textContent = sideBySideMarginStyle;
2505
+ document.head.appendChild(style);
2318
2506
  }
2319
2507
  factory.autoStartDefault = settings.get('autoStartDefaultKernel')
2320
2508
  .composite as boolean;
@@ -2532,6 +2720,7 @@ function activateNotebookCompleterService(
2532
2720
  keys: ['Enter'],
2533
2721
  selector: '.jp-Notebook .jp-mod-completer-active'
2534
2722
  });
2723
+ const completerConnected = new WeakSet<NotebookPanel>();
2535
2724
  const updateCompleter = async (
2536
2725
  _: INotebookTracker | undefined,
2537
2726
  notebook: NotebookPanel
@@ -2543,6 +2732,15 @@ function activateNotebookCompleterService(
2543
2732
  sanitizer: sanitizer
2544
2733
  };
2545
2734
  await manager.updateCompleter(completerContext);
2735
+ // `updateCompleter` also runs for every panel on `activeProvidersChanged`
2736
+ // below; connect the listeners only on the first call for a panel so
2737
+ // they do not accumulate per settings change. The disposal check covers
2738
+ // a panel closed while `updateCompleter` was pending: connections made
2739
+ // then would outlive the disposal cleanup that has already run.
2740
+ if (notebook.isDisposed || completerConnected.has(notebook)) {
2741
+ return;
2742
+ }
2743
+ completerConnected.add(notebook);
2546
2744
  notebook.content.activeCellChanged.connect((_, cell) => {
2547
2745
  // Ensure the editor will exist on the cell before adding the completer
2548
2746
  cell?.ready
@@ -2557,6 +2755,10 @@ function activateNotebookCompleterService(
2557
2755
  })
2558
2756
  .catch(console.error);
2559
2757
  });
2758
+ // The session context outlives this panel when other views of the
2759
+ // document stay open, so the connection is made with the panel as
2760
+ // receiver: `Widget.dispose()` calls `Signal.clearData(this)`, which
2761
+ // removes it when the panel is closed.
2560
2762
  notebook.sessionContext.sessionChanged.connect(() => {
2561
2763
  // Ensure the editor will exist on the cell before adding the completer
2562
2764
  notebook.content.activeCell?.ready
@@ -2569,7 +2771,7 @@ function activateNotebookCompleterService(
2569
2771
  return manager.updateCompleter(newCompleterContext);
2570
2772
  })
2571
2773
  .catch(console.error);
2572
- });
2774
+ }, notebook);
2573
2775
  };
2574
2776
  notebooks.widgetAdded.connect(updateCompleter);
2575
2777
  manager.activeProvidersChanged.connect(() => {
@@ -2709,18 +2911,28 @@ function addCommands(
2709
2911
  }
2710
2912
  };
2711
2913
 
2712
- // Set up signal handler to keep the collapse state consistent
2914
+ // Set up signal handler to keep the collapse state consistent. The
2915
+ // connections are made once per panel: `currentChanged` fires repeatedly
2916
+ // for the same panel, and reconnecting each time would pile up duplicate
2917
+ // handlers for as long as the panel lives.
2918
+ const collapseSynchronized = new WeakSet<NotebookPanel>();
2713
2919
  tracker.currentChanged.connect(
2714
2920
  (sender: INotebookTracker, panel: NotebookPanel) => {
2715
- if (!panel?.content?.model?.cells) {
2921
+ if (!panel?.content?.model?.cells || collapseSynchronized.has(panel)) {
2716
2922
  return;
2717
2923
  }
2924
+ collapseSynchronized.add(panel);
2925
+ // The cell list belongs to the model, which outlives this view when
2926
+ // other views of the document stay open, so the connection is made
2927
+ // with the notebook as receiver: `Widget.dispose()` calls
2928
+ // `Signal.clearData(this)`, which removes it when the view is closed.
2718
2929
  panel.content.model.cells.changed.connect(
2719
2930
  (list: any, args: IObservableList.IChangedArgs<ICellModel>) => {
2720
2931
  // Might be overkill to refresh this every time, but
2721
2932
  // it helps to keep the collapse state consistent.
2722
2933
  refreshCellCollapsed(panel.content);
2723
- }
2934
+ },
2935
+ panel.content
2724
2936
  );
2725
2937
  panel.content.activeCellChanged.connect(
2726
2938
  (notebook: Notebook, cell: Cell) => {
@@ -3077,8 +3289,14 @@ function addCommands(
3077
3289
  commands.addCommand(CommandIDs.trust, {
3078
3290
  label: () => trans.__('Trust Notebook'),
3079
3291
  execute: async args => {
3080
- const current = getCurrent(tracker, shell, args);
3081
- if (current) {
3292
+ const trustBoundaryId = args[CommandLinker.TRUST_BOUNDARY_ID_ARG];
3293
+ const current =
3294
+ typeof trustBoundaryId === 'string'
3295
+ ? (tracker.find(
3296
+ widget => widget.content.node.id === trustBoundaryId
3297
+ ) ?? null)
3298
+ : getCurrent(tracker, shell, args);
3299
+ if (current && !current.isDisposed) {
3082
3300
  const { context, content } = current;
3083
3301
  const trustResult = await NotebookActions.trust(content);
3084
3302
  if (trustResult.trusted) {
@@ -3092,7 +3310,12 @@ function addCommands(
3092
3310
  describedBy: {
3093
3311
  args: {
3094
3312
  type: 'object',
3095
- properties: {}
3313
+ properties: {
3314
+ [CommandLinker.TRUST_BOUNDARY_ID_ARG]: {
3315
+ type: 'string',
3316
+ description: trans.__('The notebook trust boundary ID')
3317
+ }
3318
+ }
3096
3319
  }
3097
3320
  }
3098
3321
  });
package/src/nboutput.ts CHANGED
@@ -65,12 +65,19 @@ function activateNBOutput(
65
65
  // There is overlap here since unhandled messages are also emitted in the
66
66
  // iopubMessage signal. However, unhandled messages warrant a higher log
67
67
  // severity, so we'll accept that they are logged twice.
68
+ //
69
+ // The session context outlives this panel when other views of the
70
+ // document stay open, so the connections are made with the panel as
71
+ // receiver: `Widget.dispose()` calls `Signal.clearData(this)`, which
72
+ // removes them when the panel is closed.
68
73
  nb.context.sessionContext.iopubMessage.connect(
69
- (_, msg: KernelMessage.IIOPubMessage) => logOutput(msg, 'info', 'info')
74
+ (_, msg: KernelMessage.IIOPubMessage) => logOutput(msg, 'info', 'info'),
75
+ nb
70
76
  );
71
77
  nb.context.sessionContext.unhandledMessage.connect(
72
78
  (_, msg: KernelMessage.IIOPubMessage) =>
73
- logOutput(msg, 'warning', 'error')
79
+ logOutput(msg, 'warning', 'error'),
80
+ nb
74
81
  );
75
82
  }
76
83
  nbtracker.forEach(nb => registerNB(nb));