@jupyterlab/running 4.2.0-alpha.1 → 4.2.0-beta.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.
package/src/index.tsx CHANGED
@@ -6,32 +6,50 @@
6
6
  */
7
7
 
8
8
  import { Dialog, showDialog } from '@jupyterlab/apputils';
9
- import { ITranslator, nullTranslator } from '@jupyterlab/translation';
9
+ import {
10
+ ITranslator,
11
+ nullTranslator,
12
+ TranslationBundle
13
+ } from '@jupyterlab/translation';
10
14
  import {
11
15
  caretDownIcon,
12
16
  caretRightIcon,
13
17
  closeIcon,
18
+ collapseAllIcon,
19
+ expandAllIcon,
20
+ FilterBox,
21
+ IScore,
14
22
  LabIcon,
15
23
  PanelWithToolbar,
16
24
  ReactWidget,
17
25
  refreshIcon,
18
26
  SidePanel,
27
+ tableRowsIcon,
28
+ Toolbar,
19
29
  ToolbarButton,
20
30
  ToolbarButtonComponent,
31
+ treeViewIcon,
21
32
  UseSignal
22
33
  } from '@jupyterlab/ui-components';
34
+ import { IStateDB } from '@jupyterlab/statedb';
23
35
  import { Token } from '@lumino/coreutils';
24
36
  import { DisposableDelegate, IDisposable } from '@lumino/disposable';
37
+ import { ElementExt } from '@lumino/domutils';
25
38
  import { Message } from '@lumino/messaging';
26
39
  import { ISignal, Signal } from '@lumino/signaling';
27
- import { Widget } from '@lumino/widgets';
28
- import * as React from 'react';
40
+ import { Panel, Widget } from '@lumino/widgets';
41
+ import React, { isValidElement, ReactNode } from 'react';
29
42
 
30
43
  /**
31
44
  * The class name added to a running widget.
32
45
  */
33
46
  const RUNNING_CLASS = 'jp-RunningSessions';
34
47
 
48
+ /**
49
+ * The class name added to a searchable widget.
50
+ */
51
+ const SEARCHABLE_CLASS = 'jp-SearchableSessions';
52
+
35
53
  /**
36
54
  * The class name added to the running terminal sessions section.
37
55
  */
@@ -73,13 +91,51 @@ const SHUTDOWN_BUTTON_CLASS = 'jp-RunningSessions-itemShutdown';
73
91
  const SHUTDOWN_ALL_BUTTON_CLASS = 'jp-RunningSessions-shutdownAll';
74
92
 
75
93
  /**
76
- * The running sessions token.
94
+ * The class name added to a collapse/expand carets.
95
+ */
96
+ const CARET_CLASS = 'jp-RunningSessions-caret';
97
+
98
+ /**
99
+ * The class name added to icons.
100
+ */
101
+ const ITEM_ICON_CLASS = 'jp-RunningSessions-icon';
102
+
103
+ /**
104
+ * Modifier added to a section when flattened list view is requested.
105
+ */
106
+ const LIST_VIEW_CLASS = 'jp-mod-running-list-view';
107
+
108
+ /**
109
+ * The class name added to button switching between nested and flat view.
110
+ */
111
+ const VIEW_BUTTON_CLASS = 'jp-RunningSessions-viewButton';
112
+
113
+ /**
114
+ * The class name added to button switching between nested and flat view.
115
+ */
116
+ const COLLAPSE_EXPAND_BUTTON_CLASS = 'jp-RunningSessions-collapseButton';
117
+
118
+ /**
119
+ * Identifier used in the state database.
120
+ */
121
+ const STATE_DB_ID = 'jp-running-sessions';
122
+
123
+ /**
124
+ * The running sessions managers token.
77
125
  */
78
126
  export const IRunningSessionManagers = new Token<IRunningSessionManagers>(
79
127
  '@jupyterlab/running:IRunningSessionManagers',
80
128
  'A service to add running session managers.'
81
129
  );
82
130
 
131
+ /**
132
+ * The running sessions token.
133
+ */
134
+ export const IRunningSessionSidebar = new Token<IRunningSessionSidebar>(
135
+ '@jupyterlab/running:IRunningSessionsSidebar',
136
+ 'A token allowing to modify the running sessions sidebar.'
137
+ );
138
+
83
139
  /**
84
140
  * The running interface.
85
141
  */
@@ -143,9 +199,10 @@ export class RunningSessionManagers implements IRunningSessionManagers {
143
199
  function Item(props: {
144
200
  child?: boolean;
145
201
  runningItem: IRunningSessions.IRunningItem;
146
- shutdownLabel?: string;
202
+ shutdownLabel?: string | ((item: IRunningSessions.IRunningItem) => string);
147
203
  shutdownItemIcon?: LabIcon;
148
204
  translator?: ITranslator;
205
+ collapseToggled: ISignal<Section, boolean>;
149
206
  }) {
150
207
  const { runningItem } = props;
151
208
  const classList = [ITEM_CLASS];
@@ -158,25 +215,39 @@ function Item(props: {
158
215
  // Handle shutdown requests.
159
216
  let stopPropagation = false;
160
217
  const shutdownItemIcon = props.shutdownItemIcon || closeIcon;
161
- const shutdownLabel = props.shutdownLabel || trans.__('Shut Down');
218
+ const shutdownLabel =
219
+ (typeof props.shutdownLabel === 'function'
220
+ ? props.shutdownLabel(runningItem)
221
+ : props.shutdownLabel) ?? trans.__('Shut Down');
162
222
  const shutdown = () => {
163
223
  stopPropagation = true;
164
224
  runningItem.shutdown?.();
165
225
  };
166
226
 
227
+ // Materialise getter to avoid triggering it repeatedly
228
+ const children = runningItem.children;
229
+
167
230
  // Manage collapsed state. Use the shutdown flag in lieu of `stopPropagation`.
168
231
  const [collapsed, collapse] = React.useState(false);
169
- const collapsible = !!runningItem.children?.length;
232
+ const collapsible = !!children?.length;
170
233
  const onClick = collapsible
171
234
  ? () => !stopPropagation && collapse(!collapsed)
172
235
  : undefined;
173
236
 
237
+ // Listen to signal to collapse from outside
238
+ props.collapseToggled.connect((_emitter, newCollapseState) =>
239
+ collapse(newCollapseState)
240
+ );
241
+
174
242
  if (runningItem.className) {
175
243
  classList.push(runningItem.className);
176
244
  }
177
245
  if (props.child) {
178
246
  classList.push('jp-mod-running-child');
179
247
  }
248
+ if (props.child && !children) {
249
+ classList.push('jp-mod-running-leaf');
250
+ }
180
251
 
181
252
  return (
182
253
  <>
@@ -188,15 +259,15 @@ function Item(props: {
188
259
  >
189
260
  {collapsible &&
190
261
  (collapsed ? (
191
- <caretRightIcon.react tag="span" stylesheet="runningItem" />
262
+ <caretRightIcon.react tag="span" className={CARET_CLASS} />
192
263
  ) : (
193
- <caretDownIcon.react tag="span" stylesheet="runningItem" />
264
+ <caretDownIcon.react tag="span" className={CARET_CLASS} />
194
265
  ))}
195
266
  {icon ? (
196
267
  typeof icon === 'string' ? (
197
- <img src={icon} />
268
+ <img src={icon} className={ITEM_ICON_CLASS} />
198
269
  ) : (
199
- <icon.react tag="span" stylesheet="runningItem" />
270
+ <icon.react tag="span" className={ITEM_ICON_CLASS} />
200
271
  )
201
272
  ) : undefined}
202
273
  <span
@@ -219,9 +290,10 @@ function Item(props: {
219
290
  {collapsible && !collapsed && (
220
291
  <List
221
292
  child={true}
222
- runningItems={runningItem.children!}
293
+ runningItems={children!}
223
294
  shutdownItemIcon={shutdownItemIcon}
224
295
  translator={translator}
296
+ collapseToggled={props.collapseToggled}
225
297
  />
226
298
  )}
227
299
  </li>
@@ -232,14 +304,31 @@ function Item(props: {
232
304
  function List(props: {
233
305
  child?: boolean;
234
306
  runningItems: IRunningSessions.IRunningItem[];
235
- shutdownLabel?: string;
307
+ shutdownLabel?: string | ((item: IRunningSessions.IRunningItem) => string);
236
308
  shutdownAllLabel?: string;
237
309
  shutdownItemIcon?: LabIcon;
310
+ filter?: (item: IRunningSessions.IRunningItem) => Partial<IScore> | null;
238
311
  translator?: ITranslator;
312
+ collapseToggled: ISignal<Section, boolean>;
239
313
  }) {
314
+ const filter = props.filter;
315
+ const items = filter
316
+ ? props.runningItems
317
+ .map(item => {
318
+ return {
319
+ item,
320
+ score: filter(item)
321
+ };
322
+ })
323
+ .filter(({ score }) => score !== null)
324
+ .sort((a, b) => {
325
+ return a.score!.score! - b.score!.score!;
326
+ })
327
+ .map(({ item }) => item)
328
+ : props.runningItems;
240
329
  return (
241
330
  <ul className={LIST_CLASS}>
242
- {props.runningItems.map((item, i) => (
331
+ {items.map((item, i) => (
243
332
  <Item
244
333
  child={props.child}
245
334
  key={i}
@@ -247,27 +336,105 @@ function List(props: {
247
336
  shutdownLabel={props.shutdownLabel}
248
337
  shutdownItemIcon={props.shutdownItemIcon}
249
338
  translator={props.translator}
339
+ collapseToggled={props.collapseToggled}
250
340
  />
251
341
  ))}
252
342
  </ul>
253
343
  );
254
344
  }
255
345
 
346
+ interface IFilterProvider {
347
+ filter(item: IRunningSessions.IRunningItem): Partial<IScore> | null;
348
+ filterChanged: ISignal<IFilterProvider, void>;
349
+ }
350
+
351
+ class FilterWidget extends ReactWidget implements IFilterProvider {
352
+ constructor(translator: ITranslator) {
353
+ super();
354
+ this.filter = this.filter.bind(this);
355
+ this._updateFilter = this._updateFilter.bind(this);
356
+ this._trans = translator.load('jupyterlab');
357
+ this.addClass('jp-SearchableSessions-filter');
358
+ }
359
+
360
+ get filterChanged(): ISignal<FilterWidget, void> {
361
+ return this._filterChanged;
362
+ }
363
+
364
+ render(): JSX.Element {
365
+ return (
366
+ <FilterBox
367
+ placeholder={this._trans.__('Search')}
368
+ updateFilter={this._updateFilter}
369
+ useFuzzyFilter={false}
370
+ caseSensitive={false}
371
+ />
372
+ );
373
+ }
374
+
375
+ filter(item: IRunningSessions.IRunningItem): Partial<IScore> | null {
376
+ const labels: string[] = [this._getTextContent(item.label())];
377
+ for (const child of item.children ?? []) {
378
+ labels.push(this._getTextContent(child.label()));
379
+ }
380
+ return this._filterFn(labels.join(' '));
381
+ }
382
+
383
+ private _getTextContent(node: ReactNode): string {
384
+ if (typeof node === 'string') {
385
+ return node;
386
+ }
387
+ if (typeof node === 'number') {
388
+ return '' + node;
389
+ }
390
+ if (typeof node === 'boolean') {
391
+ return '' + node;
392
+ }
393
+ if (Array.isArray(node)) {
394
+ return node.map(n => this._getTextContent(n)).join(' ');
395
+ }
396
+ if (node && isValidElement(node)) {
397
+ return node.props.children
398
+ .map((n: ReactNode) => this._getTextContent(n))
399
+ .join(' ');
400
+ }
401
+ return '';
402
+ }
403
+
404
+ private _updateFilter(
405
+ filterFn: (item: string) => Partial<IScore> | null
406
+ ): void {
407
+ this._filterFn = filterFn;
408
+ this._filterChanged.emit();
409
+ }
410
+
411
+ private _filterFn: (item: string) => Partial<IScore> | null = (_: string) => {
412
+ return { score: 0 };
413
+ };
414
+ private _filterChanged = new Signal<FilterWidget, void>(this);
415
+ private _trans: TranslationBundle;
416
+ }
417
+
256
418
  class ListWidget extends ReactWidget {
257
419
  constructor(
258
420
  private _options: {
259
421
  manager: IRunningSessions.IManager;
260
422
  runningItems: IRunningSessions.IRunningItem[];
261
423
  shutdownAllLabel: string;
424
+ filterProvider?: IFilterProvider;
262
425
  translator?: ITranslator;
426
+ collapseToggled: ISignal<Section, boolean>;
263
427
  }
264
428
  ) {
265
429
  super();
266
430
  _options.manager.runningChanged.connect(this._emitUpdate, this);
431
+ if (_options.filterProvider) {
432
+ _options.filterProvider.filterChanged.connect(this._emitUpdate, this);
433
+ }
267
434
  }
268
435
 
269
436
  dispose() {
270
- this._options.manager.runningChanged.disconnect(this._emitUpdate, this);
437
+ Signal.clearData(this);
271
438
  super.dispose();
272
439
  }
273
440
 
@@ -282,7 +449,7 @@ class ListWidget extends ReactWidget {
282
449
  return (
283
450
  <UseSignal signal={this._update}>
284
451
  {() => {
285
- // Cache the running items for the intial load and request from
452
+ // Cache the running items for the initial load and request from
286
453
  // the service every subsequent load.
287
454
  if (cached) {
288
455
  cached = false;
@@ -296,7 +463,9 @@ class ListWidget extends ReactWidget {
296
463
  shutdownLabel={options.manager.shutdownLabel}
297
464
  shutdownAllLabel={options.shutdownAllLabel}
298
465
  shutdownItemIcon={options.manager.shutdownItemIcon}
466
+ filter={options.filterProvider?.filter}
299
467
  translator={options.translator}
468
+ collapseToggled={options.collapseToggled}
300
469
  />
301
470
  </div>
302
471
  );
@@ -345,25 +514,79 @@ class ListWidget extends ReactWidget {
345
514
  * It is specialized for each based on its props.
346
515
  */
347
516
  class Section extends PanelWithToolbar {
348
- constructor(options: {
349
- manager: IRunningSessions.IManager;
350
- translator?: ITranslator;
351
- }) {
517
+ constructor(options: Section.IOptions) {
352
518
  super();
353
519
  this._manager = options.manager;
520
+ this._filterProvider = options.filterProvider;
354
521
  const translator = options.translator || nullTranslator;
355
- const trans = translator.load('jupyterlab');
356
- const shutdownAllLabel =
357
- options.manager.shutdownAllLabel || trans.__('Shut Down All');
358
- const shutdownTitle = `${shutdownAllLabel}?`;
359
- const shutdownAllConfirmationText =
360
- options.manager.shutdownAllConfirmationText ||
361
- `${shutdownAllLabel} ${options.manager.name}`;
522
+ this._trans = translator.load('jupyterlab');
362
523
 
363
524
  this.addClass(SECTION_CLASS);
364
525
  this.title.label = options.manager.name;
365
526
 
366
- function onShutdown() {
527
+ this._manager.runningChanged.connect(this._onListChanged, this);
528
+ if (options.filterProvider) {
529
+ options.filterProvider.filterChanged.connect(this._onListChanged, this);
530
+ }
531
+ this._updateEmptyClass();
532
+
533
+ let runningItems = options.manager.running();
534
+
535
+ if (options.showToolbar !== false) {
536
+ this._initializeToolbar(runningItems);
537
+ }
538
+
539
+ this.addWidget(
540
+ new ListWidget({
541
+ runningItems,
542
+ shutdownAllLabel: this._shutdownAllLabel,
543
+ collapseToggled: this._collapseToggled,
544
+ ...options
545
+ })
546
+ );
547
+ }
548
+
549
+ /**
550
+ * Toggle between list and tree view.
551
+ */
552
+ toggleListView(forceOn?: boolean): void {
553
+ const newState = typeof forceOn !== 'undefined' ? forceOn : !this._listView;
554
+ this._listView = newState;
555
+ if (this._buttons) {
556
+ const switchViewButton = this._buttons['switch-view'];
557
+ switchViewButton.pressed = newState;
558
+ }
559
+ this._collapseToggled.emit(false);
560
+ this.toggleClass(LIST_VIEW_CLASS, newState);
561
+ this._updateButtons();
562
+ this._viewChanged.emit({ mode: newState ? 'list' : 'tree' });
563
+ }
564
+
565
+ /**
566
+ * Dispose the resources held by the widget
567
+ */
568
+ dispose(): void {
569
+ if (this.isDisposed) {
570
+ return;
571
+ }
572
+ Signal.clearData(this);
573
+ super.dispose();
574
+ }
575
+
576
+ private get _shutdownAllLabel(): string {
577
+ return this._manager.shutdownAllLabel || this._trans.__('Shut Down All');
578
+ }
579
+
580
+ private _initializeToolbar(runningItems: IRunningSessions.IRunningItem[]) {
581
+ const enabled = runningItems.length > 0;
582
+
583
+ const shutdownAllLabel = this._shutdownAllLabel;
584
+ const shutdownTitle = `${shutdownAllLabel}?`;
585
+ const shutdownAllConfirmationText =
586
+ this._manager.shutdownAllConfirmationText ||
587
+ `${shutdownAllLabel} ${this._manager.name}`;
588
+
589
+ const onShutdown = () => {
367
590
  void showDialog({
368
591
  title: shutdownTitle,
369
592
  body: shutdownAllConfirmationText,
@@ -373,67 +596,168 @@ class Section extends PanelWithToolbar {
373
596
  ]
374
597
  }).then(result => {
375
598
  if (result.button.accept) {
376
- options.manager.shutdownAll();
599
+ this._manager.shutdownAll();
377
600
  }
378
601
  });
379
- }
602
+ };
380
603
 
381
- let runningItems = options.manager.running();
382
- const enabled = runningItems.length > 0;
383
- this._button = new ToolbarButton({
604
+ const shutdownAllButton = new ToolbarButton({
384
605
  label: shutdownAllLabel,
385
606
  className: `${SHUTDOWN_ALL_BUTTON_CLASS}${
386
607
  !enabled ? ' jp-mod-disabled' : ''
387
608
  }`,
388
609
  enabled,
389
- onClick: onShutdown
610
+ onClick: onShutdown.bind(this)
611
+ });
612
+ const switchViewButton = new ToolbarButton({
613
+ className: VIEW_BUTTON_CLASS,
614
+ enabled,
615
+ icon: tableRowsIcon,
616
+ pressedIcon: treeViewIcon,
617
+ onClick: () => this.toggleListView(),
618
+ tooltip: this._trans.__('Switch to List View'),
619
+ pressedTooltip: this._trans.__('Switch to Tree View')
620
+ });
621
+ const collapseExpandAllButton = new ToolbarButton({
622
+ className: COLLAPSE_EXPAND_BUTTON_CLASS,
623
+ enabled,
624
+ icon: collapseAllIcon,
625
+ pressedIcon: expandAllIcon,
626
+ onClick: () => {
627
+ const newState = !collapseExpandAllButton.pressed;
628
+ this._collapseToggled.emit(newState);
629
+ collapseExpandAllButton.pressed = newState;
630
+ },
631
+ tooltip: this._trans.__('Collapse All'),
632
+ pressedTooltip: this._trans.__('Expand All')
390
633
  });
391
- this._manager.runningChanged.connect(this._updateButton, this);
392
634
 
393
- this.toolbar.addItem('shutdown-all', this._button);
635
+ this._buttons = {
636
+ 'switch-view': switchViewButton,
637
+ 'collapse-expand': collapseExpandAllButton,
638
+ 'shutdown-all': shutdownAllButton
639
+ };
640
+ // Update buttons once defined and before adding to DOM
641
+ this._updateButtons();
642
+ this._manager.runningChanged.connect(this._updateButtons, this);
643
+
644
+ for (const name of ['collapse-expand', 'switch-view', 'shutdown-all']) {
645
+ this.toolbar.addItem(
646
+ name,
647
+ this._buttons[name as keyof typeof this._buttons]
648
+ );
649
+ }
650
+ this.toolbar.addClass('jp-RunningSessions-toolbar');
651
+ }
394
652
 
395
- this.addWidget(
396
- new ListWidget({ runningItems, shutdownAllLabel, ...options })
397
- );
653
+ private _onListChanged(): void {
654
+ this._updateButtons();
655
+ this._updateEmptyClass();
398
656
  }
399
657
 
400
- /**
401
- * Dispose the resources held by the widget
402
- */
403
- dispose(): void {
404
- if (this.isDisposed) {
405
- return;
658
+ private _updateEmptyClass(): void {
659
+ if (this._filterProvider) {
660
+ const items = this._manager.running().filter(this._filterProvider.filter);
661
+ const empty = items.length === 0;
662
+ if (empty) {
663
+ this.node.classList.toggle('jp-mod-empty', true);
664
+ } else {
665
+ this.node.classList.toggle('jp-mod-empty', false);
666
+ }
406
667
  }
407
- this._manager.runningChanged.disconnect(this._updateButton, this);
408
- super.dispose();
409
668
  }
410
669
 
411
- private _updateButton(): void {
412
- const button = this._button;
413
- button.enabled = this._manager.running().length > 0;
414
- if (button.enabled) {
415
- button.node
416
- .querySelector('jp-button')
417
- ?.classList.remove('jp-mod-disabled');
418
- } else {
419
- button.node.querySelector('jp-button')?.classList.add('jp-mod-disabled');
670
+ get viewChanged(): ISignal<Section, Section.IViewState> {
671
+ return this._viewChanged;
672
+ }
673
+
674
+ private _updateButtons(): void {
675
+ if (!this._buttons) {
676
+ return;
420
677
  }
678
+ let runningItems = this._manager.running();
679
+ const enabled = runningItems.length > 0;
680
+
681
+ const hasNesting = runningItems.filter(item => item.children).length !== 0;
682
+ const inTreeView = hasNesting && !this._buttons['switch-view'].pressed;
683
+
684
+ this._buttons['switch-view'].node.style.display = hasNesting
685
+ ? 'block'
686
+ : 'none';
687
+ this._buttons['collapse-expand'].node.style.display = inTreeView
688
+ ? 'block'
689
+ : 'none';
690
+
691
+ this._buttons['collapse-expand'].enabled = enabled;
692
+ this._buttons['switch-view'].enabled = enabled;
693
+ this._buttons['shutdown-all'].enabled = enabled;
421
694
  }
422
695
 
423
- private _button: ToolbarButton;
696
+ private _buttons: {
697
+ 'collapse-expand': ToolbarButton;
698
+ 'switch-view': ToolbarButton;
699
+ 'shutdown-all': ToolbarButton;
700
+ } | null = null;
424
701
  private _manager: IRunningSessions.IManager;
702
+ private _listView: boolean = false;
703
+ private _filterProvider?: IFilterProvider;
704
+ private _collapseToggled = new Signal<Section, boolean>(this);
705
+ private _viewChanged = new Signal<Section, Section.IViewState>(this);
706
+ private _trans: TranslationBundle;
707
+ }
708
+
709
+ /**
710
+ * Statics for Section.
711
+ */
712
+ namespace Section {
713
+ /**
714
+ * Initialisation options for section.
715
+ */
716
+ export interface IOptions {
717
+ manager: IRunningSessions.IManager;
718
+ showToolbar?: boolean;
719
+ filterProvider?: IFilterProvider;
720
+ translator?: ITranslator;
721
+ }
722
+ /**
723
+ * Information about section view state.
724
+ */
725
+ export interface IViewState {
726
+ /**
727
+ * View mode
728
+ */
729
+ mode: 'tree' | 'list';
730
+ }
731
+ }
732
+
733
+ /**
734
+ * The interface exposing the running sessions sidebar widget properties.
735
+ */
736
+ export interface IRunningSessionSidebar {
737
+ /**
738
+ * The toolbar of the running sidebar.
739
+ */
740
+ readonly toolbar: Toolbar;
425
741
  }
426
742
 
427
743
  /**
428
744
  * A class that exposes the running terminal and kernel sessions.
429
745
  */
430
- export class RunningSessions extends SidePanel {
746
+ export class RunningSessions
747
+ extends SidePanel
748
+ implements IRunningSessionSidebar
749
+ {
431
750
  /**
432
751
  * Construct a new running widget.
433
752
  */
434
- constructor(managers: IRunningSessionManagers, translator?: ITranslator) {
753
+ constructor(
754
+ managers: IRunningSessionManagers,
755
+ translator?: ITranslator,
756
+ stateDB?: IStateDB | null
757
+ ) {
435
758
  super();
436
759
  this.managers = managers;
760
+ this._stateDB = stateDB ?? null;
437
761
  this.translator = translator ?? nullTranslator;
438
762
  const trans = this.translator.load('jupyterlab');
439
763
 
@@ -450,7 +774,6 @@ export class RunningSessions extends SidePanel {
450
774
  );
451
775
 
452
776
  managers.items().forEach(manager => this.addSection(managers, manager));
453
-
454
777
  managers.added.connect(this.addSection, this);
455
778
  }
456
779
 
@@ -471,12 +794,302 @@ export class RunningSessions extends SidePanel {
471
794
  * @param managers Managers
472
795
  * @param manager New manager
473
796
  */
474
- protected addSection(_: unknown, manager: IRunningSessions.IManager) {
475
- this.addWidget(new Section({ manager, translator: this.translator }));
797
+ protected async addSection(_: unknown, manager: IRunningSessions.IManager) {
798
+ const section = new Section({ manager, translator: this.translator });
799
+ this.addWidget(section);
800
+
801
+ const state = await this._getState();
802
+ const sectionsInListView = state.listViewSections;
803
+ const sectionId = manager.name;
804
+
805
+ if (sectionsInListView && sectionsInListView.includes(sectionId)) {
806
+ section.toggleListView(true);
807
+ }
808
+ section.viewChanged.connect(
809
+ async (_emitter, viewState: Section.IViewState) => {
810
+ await this._updateState(sectionId, viewState.mode);
811
+ }
812
+ );
813
+ }
814
+
815
+ /**
816
+ * Update state database with the new state of a given section.
817
+ */
818
+ private async _updateState(sectionId: string, mode: 'list' | 'tree') {
819
+ const state = await this._getState();
820
+ let listViewSections = state.listViewSections ?? [];
821
+ if (mode === 'list' && !listViewSections.includes(sectionId)) {
822
+ listViewSections.push(sectionId);
823
+ } else {
824
+ listViewSections = listViewSections.filter(e => e !== sectionId);
825
+ }
826
+ const newState = { listViewSections };
827
+ if (this._stateDB) {
828
+ await this._stateDB.save(STATE_DB_ID, newState);
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Get current state from the state database.
834
+ */
835
+ private async _getState(): Promise<RunningSessions.IStateDBLayout> {
836
+ if (!this._stateDB) {
837
+ return {};
838
+ }
839
+ return (
840
+ ((await this._stateDB.fetch(
841
+ STATE_DB_ID
842
+ )) as RunningSessions.IStateDBLayout) ?? {}
843
+ );
476
844
  }
477
845
 
478
846
  protected managers: IRunningSessionManagers;
479
847
  protected translator: ITranslator;
848
+ private _stateDB: IStateDB | null;
849
+ }
850
+
851
+ /**
852
+ * Interfaces for RunningSessions implementation.
853
+ */
854
+ namespace RunningSessions {
855
+ /**
856
+ * Layout of the state database.
857
+ */
858
+ export interface IStateDBLayout {
859
+ /**
860
+ * Names of sections to be presented in the list view.
861
+ */
862
+ listViewSections?: string[];
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Section but rendering its own title before the content
868
+ */
869
+ class TitledSection extends Section {
870
+ constructor(options: Section.IOptions) {
871
+ super(options);
872
+ const titleNode = document.createElement('h3');
873
+ titleNode.className = 'jp-SearchableSessions-title';
874
+ const label = titleNode.appendChild(document.createElement('span'));
875
+ label.className = 'jp-SearchableSessions-titleLabel';
876
+ label.textContent = this.title.label;
877
+ this.node.insertAdjacentElement('afterbegin', titleNode);
878
+ }
879
+ }
880
+
881
+ class EmptyIndicator extends Widget {
882
+ constructor(translator: ITranslator) {
883
+ super();
884
+ const trans = translator.load('jupyterlab');
885
+ this.addClass('jp-SearchableSessions-emptyIndicator');
886
+ this.node.textContent = trans.__('No matches');
887
+ }
888
+ }
889
+
890
+ /**
891
+ * A panel intended for use within `Dialog` to allow searching tabs and running sessions.
892
+ */
893
+ export class SearchableSessions extends Panel {
894
+ constructor(managers: IRunningSessionManagers, translator?: ITranslator) {
895
+ super();
896
+ this._translator = translator ?? nullTranslator;
897
+
898
+ this.addClass(RUNNING_CLASS);
899
+ this.addClass(SEARCHABLE_CLASS);
900
+ this._filterWidget = new FilterWidget(this._translator);
901
+ this.addWidget(this._filterWidget);
902
+ this._list = new SearchableSessionsList(
903
+ managers,
904
+ this._filterWidget,
905
+ translator
906
+ );
907
+ this.addWidget(this._list);
908
+
909
+ this._filterWidget.filterChanged.connect(() => {
910
+ this._activeIndex = 0;
911
+ this._updateActive(0);
912
+ }, this);
913
+ }
914
+
915
+ /**
916
+ * Dispose the resources held by the widget
917
+ */
918
+ dispose(): void {
919
+ if (this.isDisposed) {
920
+ return;
921
+ }
922
+ Signal.clearData(this);
923
+ super.dispose();
924
+ }
925
+
926
+ /**
927
+ * Click active element when the user confirmed the choice in the dialog.
928
+ */
929
+ getValue() {
930
+ const items = [
931
+ ...this.node.querySelectorAll('.' + ITEM_LABEL_CLASS)
932
+ ] as HTMLElement[];
933
+ const pos = Math.min(Math.max(this._activeIndex, 0), items.length - 1);
934
+ items[pos].click();
935
+ }
936
+
937
+ /**
938
+ * Handle incoming events.
939
+ */
940
+ handleEvent(event: Event): void {
941
+ switch (event.type) {
942
+ case 'keydown':
943
+ this._evtKeydown(event as KeyboardEvent);
944
+ break;
945
+ }
946
+ }
947
+
948
+ /**
949
+ * A message handler invoked on an `'after-attach'` message.
950
+ */
951
+ protected onAfterAttach(_: Message): void {
952
+ this._forceFocusInput();
953
+ this.node.addEventListener('keydown', this);
954
+ setTimeout(() => {
955
+ this._updateActive(0);
956
+ }, 0);
957
+ }
958
+ /**
959
+ * A message handler invoked on an `'after-detach'` message.
960
+ */
961
+ protected onAfterDetach(_: Message): void {
962
+ this.node.removeEventListener('keydown', this);
963
+ }
964
+
965
+ /**
966
+ * Force focus on the filter input.
967
+ *
968
+ * Note: forces focus because this widget is intended to be used in `Dialog`,
969
+ * which does not support focusing React widget nested within a non-React
970
+ * widget (a limitation of `focusNodeSelector` option implementation).
971
+ */
972
+ private _forceFocusInput(): void {
973
+ this._filterWidget.renderPromise
974
+ ?.then(() => {
975
+ this._filterWidget.node.querySelector('input')?.focus();
976
+ })
977
+ .catch(console.warn);
978
+ }
979
+
980
+ /**
981
+ * Navigate between items using up/down keys by shifting focus.
982
+ */
983
+ private _evtKeydown(event: KeyboardEvent) {
984
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
985
+ const direction = event.key === 'ArrowDown' ? +1 : -1;
986
+ const wasSet = this._updateActive(direction);
987
+ if (wasSet) {
988
+ event.preventDefault();
989
+ }
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Set and mark active item relative to the current.
995
+ *
996
+ * Returns whether an active item was set.
997
+ */
998
+ private _updateActive(direction: -1 | 0 | 1): boolean {
999
+ const items = [...this.node.querySelectorAll('.' + ITEM_CLASS)].filter(e =>
1000
+ e.checkVisibility()
1001
+ ) as HTMLElement[];
1002
+ if (!items.length) {
1003
+ return false;
1004
+ }
1005
+ for (const item of items) {
1006
+ if (item.classList.contains('jp-mod-active')) {
1007
+ item.classList.toggle('jp-mod-active', false);
1008
+ }
1009
+ }
1010
+ const currentIndex = this._activeIndex;
1011
+ let newIndex: number | null = null;
1012
+ if (currentIndex === -1) {
1013
+ // First or last
1014
+ newIndex = direction === +1 ? 0 : items.length - 1;
1015
+ } else {
1016
+ newIndex = Math.min(
1017
+ Math.max(currentIndex + direction, 0),
1018
+ items.length - 1
1019
+ );
1020
+ }
1021
+ if (newIndex !== null) {
1022
+ items[newIndex].classList.add('jp-mod-active');
1023
+ ElementExt.scrollIntoViewIfNeeded(this._list.node, items[newIndex]);
1024
+ this._activeIndex = newIndex;
1025
+ return true;
1026
+ }
1027
+ return false;
1028
+ }
1029
+
1030
+ private _translator: ITranslator;
1031
+ private _filterWidget: FilterWidget;
1032
+ private _activeIndex = 0;
1033
+ private _list: SearchableSessionsList;
1034
+ }
1035
+
1036
+ /**
1037
+ * A panel list of searchable sessions.
1038
+ */
1039
+ export class SearchableSessionsList extends Panel {
1040
+ constructor(
1041
+ managers: IRunningSessionManagers,
1042
+ filterWidget: FilterWidget,
1043
+ translator?: ITranslator
1044
+ ) {
1045
+ super();
1046
+ this._managers = managers;
1047
+ this._translator = translator ?? nullTranslator;
1048
+ this._filterWidget = filterWidget;
1049
+ this.addClass('jp-SearchableSessions-list');
1050
+
1051
+ this._emptyIndicator = new EmptyIndicator(this._translator);
1052
+ this.addWidget(this._emptyIndicator);
1053
+
1054
+ managers.items().forEach(manager => this.addSection(managers, manager));
1055
+ managers.added.connect(this.addSection, this);
1056
+ }
1057
+
1058
+ /**
1059
+ * Dispose the resources held by the widget
1060
+ */
1061
+ dispose(): void {
1062
+ if (this.isDisposed) {
1063
+ return;
1064
+ }
1065
+ this._managers.added.disconnect(this.addSection, this);
1066
+ super.dispose();
1067
+ }
1068
+
1069
+ /**
1070
+ * Add a section for a new manager.
1071
+ *
1072
+ * @param managers Managers
1073
+ * @param manager New manager
1074
+ */
1075
+ protected addSection(_: unknown, manager: IRunningSessions.IManager) {
1076
+ const section = new TitledSection({
1077
+ manager,
1078
+ translator: this._translator,
1079
+ showToolbar: false,
1080
+ filterProvider: this._filterWidget
1081
+ });
1082
+ // Do not use tree view in searchable list
1083
+ section.toggleListView(true);
1084
+ this.addWidget(section);
1085
+ // Move empty indicator to the end
1086
+ this.addWidget(this._emptyIndicator);
1087
+ }
1088
+
1089
+ private _managers: IRunningSessionManagers;
1090
+ private _translator: ITranslator;
1091
+ private _emptyIndicator: EmptyIndicator;
1092
+ private _filterWidget: FilterWidget;
480
1093
  }
481
1094
 
482
1095
  /**
@@ -515,7 +1128,7 @@ export namespace IRunningSessions {
515
1128
  /**
516
1129
  * A string used to describe the shutdown action.
517
1130
  */
518
- shutdownLabel?: string;
1131
+ shutdownLabel?: string | ((item: IRunningSessions.IRunningItem) => string);
519
1132
 
520
1133
  /**
521
1134
  * A string used to describe the shutdown all action.
@@ -571,7 +1184,7 @@ export namespace IRunningSessions {
571
1184
  /**
572
1185
  * Called to determine the label for each item.
573
1186
  */
574
- label: () => string;
1187
+ label: () => ReactNode;
575
1188
 
576
1189
  /**
577
1190
  * Called to determine the `title` attribute for each item, which is