@jupyterlab/filebrowser 4.0.0-alpha.2 → 4.0.0-alpha.20

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/listing.ts ADDED
@@ -0,0 +1,2609 @@
1
+ // Copyright (c) Jupyter Development Team.
2
+ // Distributed under the terms of the Modified BSD License.
3
+
4
+ import {
5
+ Dialog,
6
+ DOMUtils,
7
+ showDialog,
8
+ showErrorMessage
9
+ } from '@jupyterlab/apputils';
10
+ import { PathExt, Time } from '@jupyterlab/coreutils';
11
+ import {
12
+ IDocumentManager,
13
+ isValidFileName,
14
+ renameFile
15
+ } from '@jupyterlab/docmanager';
16
+ import { DocumentRegistry } from '@jupyterlab/docregistry';
17
+ import { Contents } from '@jupyterlab/services';
18
+ import {
19
+ ITranslator,
20
+ nullTranslator,
21
+ TranslationBundle
22
+ } from '@jupyterlab/translation';
23
+ import {
24
+ caretDownIcon,
25
+ caretUpIcon,
26
+ classes,
27
+ LabIcon
28
+ } from '@jupyterlab/ui-components';
29
+ import { ArrayExt, filter, StringExt } from '@lumino/algorithm';
30
+ import { MimeData, PromiseDelegate } from '@lumino/coreutils';
31
+ import { ElementExt } from '@lumino/domutils';
32
+ import { Drag } from '@lumino/dragdrop';
33
+ import { Message, MessageLoop } from '@lumino/messaging';
34
+ import { ISignal, Signal } from '@lumino/signaling';
35
+ import { h, VirtualDOM } from '@lumino/virtualdom';
36
+ import { Widget } from '@lumino/widgets';
37
+ import { FilterFileBrowserModel } from './model';
38
+
39
+ /**
40
+ * The class name added to DirListing widget.
41
+ */
42
+ const DIR_LISTING_CLASS = 'jp-DirListing';
43
+
44
+ /**
45
+ * The class name added to a dir listing header node.
46
+ */
47
+ const HEADER_CLASS = 'jp-DirListing-header';
48
+
49
+ /**
50
+ * The class name added to a dir listing list header cell.
51
+ */
52
+ const HEADER_ITEM_CLASS = 'jp-DirListing-headerItem';
53
+
54
+ /**
55
+ * The class name added to a header cell text node.
56
+ */
57
+ const HEADER_ITEM_TEXT_CLASS = 'jp-DirListing-headerItemText';
58
+
59
+ /**
60
+ * The class name added to a header cell icon node.
61
+ */
62
+ const HEADER_ITEM_ICON_CLASS = 'jp-DirListing-headerItemIcon';
63
+
64
+ /**
65
+ * The class name added to the dir listing content node.
66
+ */
67
+ const CONTENT_CLASS = 'jp-DirListing-content';
68
+
69
+ /**
70
+ * The class name added to dir listing content item.
71
+ */
72
+ const ITEM_CLASS = 'jp-DirListing-item';
73
+
74
+ /**
75
+ * The class name added to the listing item text cell.
76
+ */
77
+ const ITEM_TEXT_CLASS = 'jp-DirListing-itemText';
78
+
79
+ /**
80
+ * The class name added to the listing item icon cell.
81
+ */
82
+ const ITEM_ICON_CLASS = 'jp-DirListing-itemIcon';
83
+
84
+ /**
85
+ * The class name added to the listing item modified cell.
86
+ */
87
+ const ITEM_MODIFIED_CLASS = 'jp-DirListing-itemModified';
88
+
89
+ /**
90
+ * The class name added to the listing item file size cell.
91
+ */
92
+ const ITEM_FILE_SIZE_CLASS = 'jp-DirListing-itemFileSize';
93
+
94
+ /**
95
+ * The class name added to the label element that wraps each item's checkbox and
96
+ * the header's check-all checkbox.
97
+ */
98
+ const CHECKBOX_WRAPPER_CLASS = 'jp-DirListing-checkboxWrapper';
99
+
100
+ /**
101
+ * The class name added to the dir listing editor node.
102
+ */
103
+ const EDITOR_CLASS = 'jp-DirListing-editor';
104
+
105
+ /**
106
+ * The class name added to the name column header cell.
107
+ */
108
+ const NAME_ID_CLASS = 'jp-id-name';
109
+
110
+ /**
111
+ * The class name added to the modified column header cell.
112
+ */
113
+ const MODIFIED_ID_CLASS = 'jp-id-modified';
114
+
115
+ /**
116
+ * The class name added to the file size column header cell.
117
+ */
118
+ const FILE_SIZE_ID_CLASS = 'jp-id-filesize';
119
+
120
+ /**
121
+ * The class name added to the narrow column header cell.
122
+ */
123
+ const NARROW_ID_CLASS = 'jp-id-narrow';
124
+
125
+ /**
126
+ * The class name added to the modified column header cell and modified item cell when hidden.
127
+ */
128
+ const MODIFIED_COLUMN_HIDDEN = 'jp-LastModified-hidden';
129
+
130
+ /**
131
+ * The class name added to the size column header cell and size item cell when hidden.
132
+ */
133
+ const FILE_SIZE_COLUMN_HIDDEN = 'jp-FileSize-hidden';
134
+
135
+ /**
136
+ * The mime type for a contents drag object.
137
+ */
138
+ const CONTENTS_MIME = 'application/x-jupyter-icontents';
139
+
140
+ /**
141
+ * The mime type for a rich contents drag object.
142
+ */
143
+ const CONTENTS_MIME_RICH = 'application/x-jupyter-icontentsrich';
144
+
145
+ /**
146
+ * The class name added to drop targets.
147
+ */
148
+ const DROP_TARGET_CLASS = 'jp-mod-dropTarget';
149
+
150
+ /**
151
+ * The class name added to selected rows.
152
+ */
153
+ const SELECTED_CLASS = 'jp-mod-selected';
154
+
155
+ /**
156
+ * The class name added to drag state icons to add space between the icon and the file name
157
+ */
158
+ const DRAG_ICON_CLASS = 'jp-DragIcon';
159
+
160
+ /**
161
+ * The class name added to the widget when there are items on the clipboard.
162
+ */
163
+ const CLIPBOARD_CLASS = 'jp-mod-clipboard';
164
+
165
+ /**
166
+ * The class name added to cut rows.
167
+ */
168
+ const CUT_CLASS = 'jp-mod-cut';
169
+
170
+ /**
171
+ * The class name added when there are more than one selected rows.
172
+ */
173
+ const MULTI_SELECTED_CLASS = 'jp-mod-multiSelected';
174
+
175
+ /**
176
+ * The class name added to indicate running notebook.
177
+ */
178
+ const RUNNING_CLASS = 'jp-mod-running';
179
+
180
+ /**
181
+ * The class name added for a descending sort.
182
+ */
183
+ const DESCENDING_CLASS = 'jp-mod-descending';
184
+
185
+ /**
186
+ * The maximum duration between two key presses when selecting files by prefix.
187
+ */
188
+ const PREFIX_APPEND_DURATION = 1000;
189
+
190
+ /**
191
+ * The threshold in pixels to start a drag event.
192
+ */
193
+ const DRAG_THRESHOLD = 5;
194
+
195
+ /**
196
+ * A boolean indicating whether the platform is Mac.
197
+ */
198
+ const IS_MAC = !!navigator.platform.match(/Mac/i);
199
+
200
+ /**
201
+ * The factory MIME type supported by lumino dock panels.
202
+ */
203
+ const FACTORY_MIME = 'application/vnd.lumino.widget-factory';
204
+
205
+ /**
206
+ * A widget which hosts a file list area.
207
+ */
208
+ export class DirListing extends Widget {
209
+ /**
210
+ * Construct a new file browser directory listing widget.
211
+ *
212
+ * @param model - The file browser view model.
213
+ */
214
+ constructor(options: DirListing.IOptions) {
215
+ super({
216
+ node: (options.renderer || DirListing.defaultRenderer).createNode()
217
+ });
218
+ this.addClass(DIR_LISTING_CLASS);
219
+ this.translator = options.translator || nullTranslator;
220
+ this._trans = this.translator.load('jupyterlab');
221
+ this._model = options.model;
222
+ this._model.fileChanged.connect(this._onFileChanged, this);
223
+ this._model.refreshed.connect(this._onModelRefreshed, this);
224
+ this._model.pathChanged.connect(this._onPathChanged, this);
225
+ this._editNode = document.createElement('input');
226
+ this._editNode.className = EDITOR_CLASS;
227
+ this._manager = this._model.manager;
228
+ this._renderer = options.renderer || DirListing.defaultRenderer;
229
+
230
+ const headerNode = DOMUtils.findElement(this.node, HEADER_CLASS);
231
+ // hide the file size column by default
232
+ this._hiddenColumns.add('file_size');
233
+ this._renderer.populateHeaderNode(
234
+ headerNode,
235
+ this.translator,
236
+ this._hiddenColumns
237
+ );
238
+ this._manager.activateRequested.connect(this._onActivateRequested, this);
239
+ }
240
+
241
+ /**
242
+ * Dispose of the resources held by the directory listing.
243
+ */
244
+ dispose(): void {
245
+ this._items.length = 0;
246
+ this._sortedItems.length = 0;
247
+ this._clipboard.length = 0;
248
+ super.dispose();
249
+ }
250
+
251
+ /**
252
+ * Get the model used by the listing.
253
+ */
254
+ get model(): FilterFileBrowserModel {
255
+ return this._model;
256
+ }
257
+
258
+ /**
259
+ * Get the dir listing header node.
260
+ *
261
+ * #### Notes
262
+ * This is the node which holds the header cells.
263
+ *
264
+ * Modifying this node directly can lead to undefined behavior.
265
+ */
266
+ get headerNode(): HTMLElement {
267
+ return DOMUtils.findElement(this.node, HEADER_CLASS);
268
+ }
269
+
270
+ /**
271
+ * Get the dir listing content node.
272
+ *
273
+ * #### Notes
274
+ * This is the node which holds the item nodes.
275
+ *
276
+ * Modifying this node directly can lead to undefined behavior.
277
+ */
278
+ get contentNode(): HTMLElement {
279
+ return DOMUtils.findElement(this.node, CONTENT_CLASS);
280
+ }
281
+
282
+ /**
283
+ * The renderer instance used by the directory listing.
284
+ */
285
+ get renderer(): DirListing.IRenderer {
286
+ return this._renderer;
287
+ }
288
+
289
+ /**
290
+ * The current sort state.
291
+ */
292
+ get sortState(): DirListing.ISortState {
293
+ return this._sortState;
294
+ }
295
+
296
+ /**
297
+ * A signal fired when an item is opened.
298
+ */
299
+ get onItemOpened(): ISignal<DirListing, Contents.IModel> {
300
+ return this._onItemOpened;
301
+ }
302
+
303
+ /**
304
+ * Create an iterator over the listing's selected items.
305
+ *
306
+ * @returns A new iterator over the listing's selected items.
307
+ */
308
+ selectedItems(): IterableIterator<Contents.IModel> {
309
+ const items = this._sortedItems;
310
+ return filter(items, item => this.selection[item.path]);
311
+ }
312
+
313
+ /**
314
+ * Create an iterator over the listing's sorted items.
315
+ *
316
+ * @returns A new iterator over the listing's sorted items.
317
+ */
318
+ sortedItems(): IterableIterator<Contents.IModel> {
319
+ return this._sortedItems[Symbol.iterator]();
320
+ }
321
+
322
+ /**
323
+ * Sort the items using a sort condition.
324
+ */
325
+ sort(state: DirListing.ISortState): void {
326
+ this._sortedItems = Private.sort(this.model.items(), state);
327
+ this._sortState = state;
328
+ this.update();
329
+ }
330
+
331
+ /**
332
+ * Rename the first currently selected item.
333
+ *
334
+ * @returns A promise that resolves with the new name of the item.
335
+ */
336
+ rename(): Promise<string> {
337
+ return this._doRename();
338
+ }
339
+
340
+ /**
341
+ * Cut the selected items.
342
+ */
343
+ cut(): void {
344
+ this._isCut = true;
345
+ this._copy();
346
+ this.update();
347
+ }
348
+
349
+ /**
350
+ * Copy the selected items.
351
+ */
352
+ copy(): void {
353
+ this._copy();
354
+ }
355
+
356
+ /**
357
+ * Paste the items from the clipboard.
358
+ *
359
+ * @returns A promise that resolves when the operation is complete.
360
+ */
361
+ paste(): Promise<void> {
362
+ if (!this._clipboard.length) {
363
+ this._isCut = false;
364
+ return Promise.resolve(undefined);
365
+ }
366
+
367
+ const basePath = this._model.path;
368
+ const promises: Promise<Contents.IModel>[] = [];
369
+
370
+ for (const path of this._clipboard) {
371
+ if (this._isCut) {
372
+ const localPath = this._manager.services.contents.localPath(path);
373
+ const parts = localPath.split('/');
374
+ const name = parts[parts.length - 1];
375
+ const newPath = PathExt.join(basePath, name);
376
+ promises.push(this._model.manager.rename(path, newPath));
377
+ } else {
378
+ promises.push(this._model.manager.copy(path, basePath));
379
+ }
380
+ }
381
+
382
+ // Remove any cut modifiers.
383
+ for (const item of this._items) {
384
+ item.classList.remove(CUT_CLASS);
385
+ }
386
+
387
+ this._clipboard.length = 0;
388
+ this._isCut = false;
389
+ this.removeClass(CLIPBOARD_CLASS);
390
+ return Promise.all(promises)
391
+ .then(() => {
392
+ return undefined;
393
+ })
394
+ .catch(error => {
395
+ void showErrorMessage(
396
+ this._trans._p('showErrorMessage', 'Paste Error'),
397
+ error
398
+ );
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Delete the currently selected item(s).
404
+ *
405
+ * @returns A promise that resolves when the operation is complete.
406
+ */
407
+ async delete(): Promise<void> {
408
+ const items = this._sortedItems.filter(item => this.selection[item.path]);
409
+
410
+ if (!items.length) {
411
+ return;
412
+ }
413
+
414
+ const message =
415
+ items.length === 1
416
+ ? this._trans.__(
417
+ 'Are you sure you want to permanently delete: %1?',
418
+ items[0].name
419
+ )
420
+ : this._trans._n(
421
+ 'Are you sure you want to permanently delete the %1 selected item?',
422
+ 'Are you sure you want to permanently delete the %1 selected items?',
423
+ items.length
424
+ );
425
+ const result = await showDialog({
426
+ title: this._trans.__('Delete'),
427
+ body: message,
428
+ buttons: [
429
+ Dialog.cancelButton({ label: this._trans.__('Cancel') }),
430
+ Dialog.warnButton({ label: this._trans.__('Delete') })
431
+ ],
432
+ // By default focus on "Cancel" to protect from accidental deletion
433
+ // ("delete" and "Enter" are next to each other on many keyboards).
434
+ defaultButton: 0
435
+ });
436
+
437
+ if (!this.isDisposed && result.button.accept) {
438
+ await this._delete(items.map(item => item.path));
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Duplicate the currently selected item(s).
444
+ *
445
+ * @returns A promise that resolves when the operation is complete.
446
+ */
447
+ duplicate(): Promise<void> {
448
+ const basePath = this._model.path;
449
+ const promises: Promise<Contents.IModel>[] = [];
450
+
451
+ for (const item of this.selectedItems()) {
452
+ if (item.type !== 'directory') {
453
+ promises.push(this._model.manager.copy(item.path, basePath));
454
+ }
455
+ }
456
+ return Promise.all(promises)
457
+ .then(() => {
458
+ return undefined;
459
+ })
460
+ .catch(error => {
461
+ void showErrorMessage(
462
+ this._trans._p('showErrorMessage', 'Duplicate file'),
463
+ error
464
+ );
465
+ });
466
+ }
467
+
468
+ /**
469
+ * Download the currently selected item(s).
470
+ */
471
+ async download(): Promise<void> {
472
+ await Promise.all(
473
+ Array.from(this.selectedItems())
474
+ .filter(item => item.type !== 'directory')
475
+ .map(item => this._model.download(item.path))
476
+ );
477
+ }
478
+
479
+ /**
480
+ * Shut down kernels on the applicable currently selected items.
481
+ *
482
+ * @returns A promise that resolves when the operation is complete.
483
+ */
484
+ shutdownKernels(): Promise<void> {
485
+ const model = this._model;
486
+ const items = this._sortedItems;
487
+ const paths = items.map(item => item.path);
488
+
489
+ const promises = Array.from(this._model.sessions())
490
+ .filter(session => {
491
+ const index = ArrayExt.firstIndexOf(paths, session.path);
492
+ return this.selection[items[index].path];
493
+ })
494
+ .map(session => model.manager.services.sessions.shutdown(session.id));
495
+
496
+ return Promise.all(promises)
497
+ .then(() => {
498
+ return undefined;
499
+ })
500
+ .catch(error => {
501
+ void showErrorMessage(
502
+ this._trans._p('showErrorMessage', 'Shut down kernel'),
503
+ error
504
+ );
505
+ });
506
+ }
507
+
508
+ /**
509
+ * Select next item.
510
+ *
511
+ * @param keepExisting - Whether to keep the current selection and add to it.
512
+ */
513
+ selectNext(keepExisting = false): void {
514
+ let index = -1;
515
+ const selected = Object.keys(this.selection);
516
+ const items = this._sortedItems;
517
+ if (selected.length === 1 || keepExisting) {
518
+ // Select the next item.
519
+ const path = selected[selected.length - 1];
520
+ index = ArrayExt.findFirstIndex(items, value => value.path === path);
521
+ index += 1;
522
+ if (index === this._items.length) {
523
+ index = 0;
524
+ }
525
+ } else if (selected.length === 0) {
526
+ // Select the first item.
527
+ index = 0;
528
+ } else {
529
+ // Select the last selected item.
530
+ const path = selected[selected.length - 1];
531
+ index = ArrayExt.findFirstIndex(items, value => value.path === path);
532
+ }
533
+ if (index !== -1) {
534
+ this._selectItem(index, keepExisting);
535
+ ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Select previous item.
541
+ *
542
+ * @param keepExisting - Whether to keep the current selection and add to it.
543
+ */
544
+ selectPrevious(keepExisting = false): void {
545
+ let index = -1;
546
+ const selected = Object.keys(this.selection);
547
+ const items = this._sortedItems;
548
+ if (selected.length === 1 || keepExisting) {
549
+ // Select the previous item.
550
+ const path = selected[0];
551
+ index = ArrayExt.findFirstIndex(items, value => value.path === path);
552
+ index -= 1;
553
+ if (index === -1) {
554
+ index = this._items.length - 1;
555
+ }
556
+ } else if (selected.length === 0) {
557
+ // Select the last item.
558
+ index = this._items.length - 1;
559
+ } else {
560
+ // Select the first selected item.
561
+ const path = selected[0];
562
+ index = ArrayExt.findFirstIndex(items, value => value.path === path);
563
+ }
564
+ if (index !== -1) {
565
+ this._selectItem(index, keepExisting);
566
+ ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Select the first item that starts with prefix being typed.
572
+ */
573
+ selectByPrefix(): void {
574
+ const prefix = this._searchPrefix.toLowerCase();
575
+ const items = this._sortedItems;
576
+
577
+ const index = ArrayExt.findFirstIndex(items, value => {
578
+ return value.name.toLowerCase().substr(0, prefix.length) === prefix;
579
+ });
580
+
581
+ if (index !== -1) {
582
+ this._selectItem(index, false);
583
+ ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Get whether an item is selected by name.
589
+ *
590
+ * @param name - The name of of the item.
591
+ *
592
+ * @returns Whether the item is selected.
593
+ */
594
+ isSelected(name: string): boolean {
595
+ const items = this._sortedItems;
596
+
597
+ return (
598
+ Array.from(
599
+ filter(items, item => item.name === name && this.selection[item.path])
600
+ ).length !== 0
601
+ );
602
+ }
603
+
604
+ /**
605
+ * Find a model given a click.
606
+ *
607
+ * @param event - The mouse event.
608
+ *
609
+ * @returns The model for the selected file.
610
+ */
611
+ modelForClick(event: MouseEvent): Contents.IModel | undefined {
612
+ const items = this._sortedItems;
613
+ const index = Private.hitTestNodes(this._items, event);
614
+ if (index !== -1) {
615
+ return items[index];
616
+ }
617
+ return undefined;
618
+ }
619
+
620
+ /**
621
+ * Clear the selected items.
622
+ */
623
+ clearSelectedItems(): void {
624
+ this.selection = Object.create(null);
625
+ }
626
+
627
+ /**
628
+ * Select an item by name.
629
+ *
630
+ * @param name - The name of the item to select.
631
+ * @param focus - Whether to move focus the selected item.
632
+ *
633
+ * @returns A promise that resolves when the name is selected.
634
+ */
635
+ async selectItemByName(name: string, focus: boolean = false): Promise<void> {
636
+ // Make sure the file is available.
637
+ await this.model.refresh();
638
+
639
+ if (this.isDisposed) {
640
+ throw new Error('File browser is disposed.');
641
+ }
642
+ const items = this._sortedItems;
643
+ const index = ArrayExt.findFirstIndex(items, value => value.name === name);
644
+ if (index === -1) {
645
+ throw new Error('Item does not exist.');
646
+ }
647
+ this._selectItem(index, false, focus);
648
+ MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest);
649
+ ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
650
+ }
651
+
652
+ /**
653
+ * Handle the DOM events for the directory listing.
654
+ *
655
+ * @param event - The DOM event sent to the widget.
656
+ *
657
+ * #### Notes
658
+ * This method implements the DOM `EventListener` interface and is
659
+ * called in response to events on the panel's DOM node. It should
660
+ * not be called directly by user code.
661
+ */
662
+ handleEvent(event: Event): void {
663
+ switch (event.type) {
664
+ case 'mousedown':
665
+ this._evtMousedown(event as MouseEvent);
666
+ break;
667
+ case 'mouseup':
668
+ this._evtMouseup(event as MouseEvent);
669
+ break;
670
+ case 'mousemove':
671
+ this._evtMousemove(event as MouseEvent);
672
+ break;
673
+ case 'keydown':
674
+ this.evtKeydown(event as KeyboardEvent);
675
+ break;
676
+ case 'click':
677
+ this._evtClick(event as MouseEvent);
678
+ break;
679
+ case 'dblclick':
680
+ this.evtDblClick(event as MouseEvent);
681
+ break;
682
+ case 'dragenter':
683
+ case 'dragover':
684
+ this.addClass('jp-mod-native-drop');
685
+ event.preventDefault();
686
+ break;
687
+ case 'dragleave':
688
+ case 'dragend':
689
+ this.removeClass('jp-mod-native-drop');
690
+ break;
691
+ case 'drop':
692
+ this.removeClass('jp-mod-native-drop');
693
+ this.evtNativeDrop(event as DragEvent);
694
+ break;
695
+ case 'scroll':
696
+ this._evtScroll(event as MouseEvent);
697
+ break;
698
+ case 'lm-dragenter':
699
+ this.evtDragEnter(event as Drag.Event);
700
+ break;
701
+ case 'lm-dragleave':
702
+ this.evtDragLeave(event as Drag.Event);
703
+ break;
704
+ case 'lm-dragover':
705
+ this.evtDragOver(event as Drag.Event);
706
+ break;
707
+ case 'lm-drop':
708
+ this.evtDrop(event as Drag.Event);
709
+ break;
710
+ default:
711
+ break;
712
+ }
713
+ }
714
+
715
+ /**
716
+ * A message handler invoked on an `'after-attach'` message.
717
+ */
718
+ protected onAfterAttach(msg: Message): void {
719
+ super.onAfterAttach(msg);
720
+ const node = this.node;
721
+ const content = DOMUtils.findElement(node, CONTENT_CLASS);
722
+ node.addEventListener('mousedown', this);
723
+ node.addEventListener('keydown', this);
724
+ node.addEventListener('click', this);
725
+ node.addEventListener('dblclick', this);
726
+ content.addEventListener('dragenter', this);
727
+ content.addEventListener('dragover', this);
728
+ content.addEventListener('dragleave', this);
729
+ content.addEventListener('dragend', this);
730
+ content.addEventListener('drop', this);
731
+ content.addEventListener('scroll', this);
732
+ content.addEventListener('lm-dragenter', this);
733
+ content.addEventListener('lm-dragleave', this);
734
+ content.addEventListener('lm-dragover', this);
735
+ content.addEventListener('lm-drop', this);
736
+ }
737
+
738
+ /**
739
+ * A message handler invoked on a `'before-detach'` message.
740
+ */
741
+ protected onBeforeDetach(msg: Message): void {
742
+ super.onBeforeDetach(msg);
743
+ const node = this.node;
744
+ const content = DOMUtils.findElement(node, CONTENT_CLASS);
745
+ node.removeEventListener('mousedown', this);
746
+ node.removeEventListener('keydown', this);
747
+ node.removeEventListener('click', this);
748
+ node.removeEventListener('dblclick', this);
749
+ content.removeEventListener('scroll', this);
750
+ content.removeEventListener('dragover', this);
751
+ content.removeEventListener('dragover', this);
752
+ content.removeEventListener('dragleave', this);
753
+ content.removeEventListener('dragend', this);
754
+ content.removeEventListener('drop', this);
755
+ content.removeEventListener('lm-dragenter', this);
756
+ content.removeEventListener('lm-dragleave', this);
757
+ content.removeEventListener('lm-dragover', this);
758
+ content.removeEventListener('lm-drop', this);
759
+ document.removeEventListener('mousemove', this, true);
760
+ document.removeEventListener('mouseup', this, true);
761
+ }
762
+
763
+ /**
764
+ * A message handler invoked on an `'after-show'` message.
765
+ */
766
+ protected onAfterShow(msg: Message): void {
767
+ if (this._isDirty) {
768
+ // Update the sorted items.
769
+ this.sort(this.sortState);
770
+ this.update();
771
+ }
772
+ }
773
+
774
+ /**
775
+ * A handler invoked on an `'update-request'` message.
776
+ */
777
+ protected onUpdateRequest(msg: Message): void {
778
+ this._isDirty = false;
779
+
780
+ // Fetch common variables.
781
+ const items = this._sortedItems;
782
+ const nodes = this._items;
783
+ const content = DOMUtils.findElement(this.node, CONTENT_CLASS);
784
+ const renderer = this._renderer;
785
+
786
+ this.removeClass(MULTI_SELECTED_CLASS);
787
+ this.removeClass(SELECTED_CLASS);
788
+
789
+ // Remove any excess item nodes.
790
+ while (nodes.length > items.length) {
791
+ content.removeChild(nodes.pop()!);
792
+ }
793
+
794
+ // Add any missing item nodes.
795
+ while (nodes.length < items.length) {
796
+ const node = renderer.createItemNode(this._hiddenColumns);
797
+ node.classList.add(ITEM_CLASS);
798
+ nodes.push(node);
799
+ content.appendChild(node);
800
+ }
801
+
802
+ // Remove extra classes from the nodes.
803
+ nodes.forEach(node => {
804
+ node.classList.remove(SELECTED_CLASS);
805
+ node.classList.remove(RUNNING_CLASS);
806
+ node.classList.remove(CUT_CLASS);
807
+ const checkbox = renderer.getCheckboxNode(node);
808
+ if (checkbox) {
809
+ // Uncheck each file checkbox
810
+ checkbox.checked = false;
811
+ }
812
+ });
813
+
814
+ // Put the check-all checkbox in the header into the correct state
815
+ const checkAllCheckbox = renderer.getCheckboxNode(this.headerNode);
816
+ if (checkAllCheckbox) {
817
+ const totalSelected = Object.keys(this.selection).length;
818
+ const allSelected = items.length > 0 && totalSelected === items.length;
819
+ const someSelected = !allSelected && totalSelected > 0;
820
+ checkAllCheckbox.checked = allSelected;
821
+ checkAllCheckbox.indeterminate = someSelected;
822
+ // Stash the state in data attributes so we can access them in the click
823
+ // handler (because in the click handler, checkbox.checked and
824
+ // checkbox.indeterminate do not hold the previous value; they hold the
825
+ // next value).
826
+ checkAllCheckbox.dataset.checked = String(allSelected);
827
+ checkAllCheckbox.dataset.indeterminate = String(someSelected);
828
+
829
+ const trans = this.translator.load('jupyterlab');
830
+ checkAllCheckbox?.setAttribute(
831
+ 'aria-label',
832
+ allSelected || someSelected
833
+ ? trans.__('Deselect all files and directories')
834
+ : trans.__('Select all files and directories')
835
+ );
836
+ }
837
+
838
+ // Add extra classes to item nodes based on widget state.
839
+ items.forEach((item, i) => {
840
+ const node = nodes[i];
841
+ const ft = this._manager.registry.getFileTypeForModel(item);
842
+ renderer.updateItemNode(
843
+ node,
844
+ item,
845
+ ft,
846
+ this.translator,
847
+ this._hiddenColumns,
848
+ this.selection[item.path]
849
+ );
850
+ if (
851
+ this.selection[item.path] &&
852
+ this._isCut &&
853
+ this._model.path === this._prevPath
854
+ ) {
855
+ node.classList.add(CUT_CLASS);
856
+ }
857
+
858
+ // add metadata to the node
859
+ node.setAttribute(
860
+ 'data-isdir',
861
+ item.type === 'directory' ? 'true' : 'false'
862
+ );
863
+ });
864
+
865
+ // Handle the selectors on the widget node.
866
+ const selected = Object.keys(this.selection).length;
867
+ if (selected) {
868
+ this.addClass(SELECTED_CLASS);
869
+ if (selected > 1) {
870
+ this.addClass(MULTI_SELECTED_CLASS);
871
+ }
872
+ }
873
+
874
+ // Handle file session statuses.
875
+ const paths = items.map(item => item.path);
876
+ for (const session of this._model.sessions()) {
877
+ const index = ArrayExt.firstIndexOf(paths, session.path);
878
+ const node = nodes[index];
879
+ // Node may have been filtered out.
880
+ if (node) {
881
+ let name = session.kernel?.name;
882
+ const specs = this._model.specs;
883
+
884
+ node.classList.add(RUNNING_CLASS);
885
+ if (specs && name) {
886
+ const spec = specs.kernelspecs[name];
887
+ name = spec ? spec.display_name : this._trans.__('unknown');
888
+ }
889
+ node.title = this._trans.__('%1\nKernel: %2', node.title, name);
890
+ }
891
+ }
892
+
893
+ this._prevPath = this._model.path;
894
+ }
895
+
896
+ onResize(msg: Widget.ResizeMessage): void {
897
+ const { width } =
898
+ msg.width === -1 ? this.node.getBoundingClientRect() : msg;
899
+ this.toggleClass('jp-DirListing-narrow', width < 250);
900
+ }
901
+
902
+ setColumnVisibility(
903
+ name: DirListing.ToggleableColumn,
904
+ visible: boolean
905
+ ): void {
906
+ if (visible) {
907
+ this._hiddenColumns.delete(name);
908
+ } else {
909
+ this._hiddenColumns.add(name);
910
+ }
911
+
912
+ this.headerNode.innerHTML = '';
913
+ this._renderer.populateHeaderNode(
914
+ this.headerNode,
915
+ this.translator,
916
+ this._hiddenColumns
917
+ );
918
+ }
919
+
920
+ /**
921
+ * Would this click (or other event type) hit the checkbox by default?
922
+ */
923
+ protected isWithinCheckboxHitArea(event: Event): boolean {
924
+ let element: HTMLElement | null = event.target as HTMLElement;
925
+ while (element) {
926
+ if (element.classList.contains(CHECKBOX_WRAPPER_CLASS)) {
927
+ return true;
928
+ }
929
+ element = element.parentElement;
930
+ }
931
+ return false;
932
+ }
933
+
934
+ /**
935
+ * Handle the `'click'` event for the widget.
936
+ */
937
+ private _evtClick(event: MouseEvent) {
938
+ const target = event.target as HTMLElement;
939
+
940
+ const header = this.headerNode;
941
+ const renderer = this._renderer;
942
+ if (header.contains(target)) {
943
+ const checkbox = renderer.getCheckboxNode(header);
944
+ if (checkbox && this.isWithinCheckboxHitArea(event)) {
945
+ const previouslyUnchecked =
946
+ checkbox.dataset.indeterminate === 'false' &&
947
+ checkbox.dataset.checked === 'false';
948
+ // The only time a click on the check-all checkbox should check all is
949
+ // when it was previously unchecked; otherwise, if the checkbox was
950
+ // either checked (all selected) or indeterminate (some selected), the
951
+ // click should clear all.
952
+ if (previouslyUnchecked) {
953
+ // Select all items
954
+ this._sortedItems.forEach(
955
+ (item: Contents.IModel) => (this.selection[item.path] = true)
956
+ );
957
+ } else {
958
+ // Unselect all items
959
+ this.clearSelectedItems();
960
+ }
961
+ this.update();
962
+ } else {
963
+ const state = this.renderer.handleHeaderClick(header, event);
964
+ if (state) {
965
+ this.sort(state);
966
+ }
967
+ }
968
+ return;
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Handle the `'scroll'` event for the widget.
974
+ */
975
+ private _evtScroll(event: MouseEvent): void {
976
+ this.headerNode.scrollLeft = this.contentNode.scrollLeft;
977
+ }
978
+
979
+ /**
980
+ * Handle the `'mousedown'` event for the widget.
981
+ */
982
+ private _evtMousedown(event: MouseEvent): void {
983
+ // Bail if clicking within the edit node
984
+ if (event.target === this._editNode) {
985
+ return;
986
+ }
987
+
988
+ // Blur the edit node if necessary.
989
+ if (this._editNode.parentNode) {
990
+ if (this._editNode !== (event.target as HTMLElement)) {
991
+ this._editNode.focus();
992
+ this._editNode.blur();
993
+ clearTimeout(this._selectTimer);
994
+ } else {
995
+ return;
996
+ }
997
+ }
998
+
999
+ let index = Private.hitTestNodes(this._items, event);
1000
+
1001
+ if (index === -1) {
1002
+ return;
1003
+ }
1004
+
1005
+ this.handleFileSelect(event);
1006
+
1007
+ if (event.button !== 0) {
1008
+ clearTimeout(this._selectTimer);
1009
+ }
1010
+
1011
+ // Check for clearing a context menu.
1012
+ const newContext = (IS_MAC && event.ctrlKey) || event.button === 2;
1013
+ if (newContext) {
1014
+ return;
1015
+ }
1016
+
1017
+ // Left mouse press for drag start.
1018
+ if (event.button === 0) {
1019
+ this._dragData = {
1020
+ pressX: event.clientX,
1021
+ pressY: event.clientY,
1022
+ index: index
1023
+ };
1024
+ document.addEventListener('mouseup', this, true);
1025
+ document.addEventListener('mousemove', this, true);
1026
+ }
1027
+ }
1028
+
1029
+ /**
1030
+ * Handle the `'mouseup'` event for the widget.
1031
+ */
1032
+ private _evtMouseup(event: MouseEvent): void {
1033
+ // Handle any soft selection from the previous mouse down.
1034
+ if (this._softSelection) {
1035
+ const altered = event.metaKey || event.shiftKey || event.ctrlKey;
1036
+ // See if we need to clear the other selection.
1037
+ if (!altered && event.button === 0) {
1038
+ this.clearSelectedItems();
1039
+ this.selection[this._softSelection] = true;
1040
+ this.update();
1041
+ }
1042
+ this._softSelection = '';
1043
+ }
1044
+ // Re-focus the selected file. This is needed because nodes corresponding
1045
+ // to files selected in mousedown handler will not retain the focus
1046
+ // as mousedown event is always followed by a blur/focus event.
1047
+ if (event.button === 0) {
1048
+ this._focusSelectedFile();
1049
+ }
1050
+
1051
+ // Remove the drag listeners if necessary.
1052
+ if (event.button !== 0 || !this._drag) {
1053
+ document.removeEventListener('mousemove', this, true);
1054
+ document.removeEventListener('mouseup', this, true);
1055
+ return;
1056
+ }
1057
+ event.preventDefault();
1058
+ event.stopPropagation();
1059
+ }
1060
+
1061
+ /**
1062
+ * Handle the `'mousemove'` event for the widget.
1063
+ */
1064
+ private _evtMousemove(event: MouseEvent): void {
1065
+ event.preventDefault();
1066
+ event.stopPropagation();
1067
+
1068
+ // Bail if we are the one dragging.
1069
+ if (this._drag || !this._dragData) {
1070
+ return;
1071
+ }
1072
+
1073
+ // Check for a drag initialization.
1074
+ const data = this._dragData;
1075
+ const dx = Math.abs(event.clientX - data.pressX);
1076
+ const dy = Math.abs(event.clientY - data.pressY);
1077
+ if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) {
1078
+ return;
1079
+ }
1080
+
1081
+ this._startDrag(data.index, event.clientX, event.clientY);
1082
+ }
1083
+
1084
+ /**
1085
+ * Handle the opening of an item.
1086
+ */
1087
+ protected handleOpen(item: Contents.IModel): void {
1088
+ this._onItemOpened.emit(item);
1089
+ if (item.type === 'directory') {
1090
+ const localPath = this._manager.services.contents.localPath(item.path);
1091
+ this._model
1092
+ .cd(`/${localPath}`)
1093
+ .catch(error =>
1094
+ showErrorMessage(
1095
+ this._trans._p('showErrorMessage', 'Open directory'),
1096
+ error
1097
+ )
1098
+ );
1099
+ } else {
1100
+ const path = item.path;
1101
+ this._manager.openOrReveal(path);
1102
+ }
1103
+ }
1104
+
1105
+ /**
1106
+ * Handle the `'keydown'` event for the widget.
1107
+ */
1108
+ protected evtKeydown(event: KeyboardEvent): void {
1109
+ switch (event.keyCode) {
1110
+ case 13: {
1111
+ // Enter
1112
+ // Do nothing if any modifier keys are pressed.
1113
+ if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
1114
+ return;
1115
+ }
1116
+ event.preventDefault();
1117
+ event.stopPropagation();
1118
+
1119
+ const selected = Object.keys(this.selection);
1120
+ const path = selected[0];
1121
+ const items = this._sortedItems;
1122
+ const i = ArrayExt.findFirstIndex(items, value => value.path === path);
1123
+ if (i === -1) {
1124
+ return;
1125
+ }
1126
+
1127
+ const item = this._sortedItems[i];
1128
+ this.handleOpen(item);
1129
+ break;
1130
+ }
1131
+ case 38: // Up arrow
1132
+ this.selectPrevious(event.shiftKey);
1133
+ event.stopPropagation();
1134
+ event.preventDefault();
1135
+ break;
1136
+ case 40: // Down arrow
1137
+ this.selectNext(event.shiftKey);
1138
+ event.stopPropagation();
1139
+ event.preventDefault();
1140
+ break;
1141
+ default:
1142
+ break;
1143
+ }
1144
+
1145
+ // Detects printable characters typed by the user.
1146
+ // Not all browsers support .key, but it discharges us from reconstructing
1147
+ // characters from key codes.
1148
+ if (!this._inRename && event.key !== undefined && event.key.length === 1) {
1149
+ if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
1150
+ return;
1151
+ }
1152
+ this._searchPrefix += event.key;
1153
+
1154
+ clearTimeout(this._searchPrefixTimer);
1155
+ this._searchPrefixTimer = window.setTimeout(() => {
1156
+ this._searchPrefix = '';
1157
+ }, PREFIX_APPEND_DURATION);
1158
+
1159
+ this.selectByPrefix();
1160
+ event.stopPropagation();
1161
+ event.preventDefault();
1162
+ }
1163
+ }
1164
+
1165
+ /**
1166
+ * Handle the `'dblclick'` event for the widget.
1167
+ */
1168
+ protected evtDblClick(event: MouseEvent): void {
1169
+ // Do nothing if it's not a left mouse press.
1170
+ if (event.button !== 0) {
1171
+ return;
1172
+ }
1173
+
1174
+ // Do nothing if any modifier keys are pressed.
1175
+ if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
1176
+ return;
1177
+ }
1178
+
1179
+ // Do nothing if the double click is on a checkbox. (Otherwise a rapid
1180
+ // check-uncheck on the checkbox will cause the adjacent file/folder to
1181
+ // open, which is probably not what the user intended.)
1182
+ if (this.isWithinCheckboxHitArea(event)) {
1183
+ return;
1184
+ }
1185
+
1186
+ // Stop the event propagation.
1187
+ event.preventDefault();
1188
+ event.stopPropagation();
1189
+
1190
+ clearTimeout(this._selectTimer);
1191
+ this._editNode.blur();
1192
+
1193
+ // Find a valid double click target.
1194
+ const target = event.target as HTMLElement;
1195
+ const i = ArrayExt.findFirstIndex(this._items, node =>
1196
+ node.contains(target)
1197
+ );
1198
+ if (i === -1) {
1199
+ return;
1200
+ }
1201
+
1202
+ const item = this._sortedItems[i];
1203
+ this.handleOpen(item);
1204
+ }
1205
+
1206
+ /**
1207
+ * Handle the `drop` event for the widget.
1208
+ */
1209
+ protected evtNativeDrop(event: DragEvent): void {
1210
+ const files = event.dataTransfer?.files;
1211
+ if (!files || files.length === 0) {
1212
+ return;
1213
+ }
1214
+ const length = event.dataTransfer?.items.length;
1215
+ if (!length) {
1216
+ return;
1217
+ }
1218
+ for (let i = 0; i < length; i++) {
1219
+ let entry = event.dataTransfer?.items[i].webkitGetAsEntry();
1220
+ if (entry?.isDirectory) {
1221
+ console.log('currently not supporting drag + drop for folders');
1222
+ void showDialog({
1223
+ title: this._trans.__('Error Uploading Folder'),
1224
+ body: this._trans.__(
1225
+ 'Drag and Drop is currently not supported for folders'
1226
+ ),
1227
+ buttons: [Dialog.cancelButton({ label: this._trans.__('Close') })]
1228
+ });
1229
+ }
1230
+ }
1231
+ event.preventDefault();
1232
+ for (let i = 0; i < files.length; i++) {
1233
+ void this._model.upload(files[i]);
1234
+ }
1235
+ }
1236
+
1237
+ /**
1238
+ * Handle the `'lm-dragenter'` event for the widget.
1239
+ */
1240
+ protected evtDragEnter(event: Drag.Event): void {
1241
+ if (event.mimeData.hasData(CONTENTS_MIME)) {
1242
+ const index = Private.hitTestNodes(this._items, event);
1243
+ if (index === -1) {
1244
+ return;
1245
+ }
1246
+ const item = this._sortedItems[index];
1247
+ if (item.type !== 'directory' || this.selection[item.path]) {
1248
+ return;
1249
+ }
1250
+ const target = event.target as HTMLElement;
1251
+ target.classList.add(DROP_TARGET_CLASS);
1252
+ event.preventDefault();
1253
+ event.stopPropagation();
1254
+ }
1255
+ }
1256
+
1257
+ /**
1258
+ * Handle the `'lm-dragleave'` event for the widget.
1259
+ */
1260
+ protected evtDragLeave(event: Drag.Event): void {
1261
+ event.preventDefault();
1262
+ event.stopPropagation();
1263
+ const dropTarget = DOMUtils.findElement(this.node, DROP_TARGET_CLASS);
1264
+ if (dropTarget) {
1265
+ dropTarget.classList.remove(DROP_TARGET_CLASS);
1266
+ }
1267
+ }
1268
+
1269
+ /**
1270
+ * Handle the `'lm-dragover'` event for the widget.
1271
+ */
1272
+ protected evtDragOver(event: Drag.Event): void {
1273
+ event.preventDefault();
1274
+ event.stopPropagation();
1275
+ event.dropAction = event.proposedAction;
1276
+ const dropTarget = DOMUtils.findElement(this.node, DROP_TARGET_CLASS);
1277
+ if (dropTarget) {
1278
+ dropTarget.classList.remove(DROP_TARGET_CLASS);
1279
+ }
1280
+ const index = Private.hitTestNodes(this._items, event);
1281
+ this._items[index].classList.add(DROP_TARGET_CLASS);
1282
+ }
1283
+
1284
+ /**
1285
+ * Handle the `'lm-drop'` event for the widget.
1286
+ */
1287
+ protected evtDrop(event: Drag.Event): void {
1288
+ event.preventDefault();
1289
+ event.stopPropagation();
1290
+ clearTimeout(this._selectTimer);
1291
+ if (event.proposedAction === 'none') {
1292
+ event.dropAction = 'none';
1293
+ return;
1294
+ }
1295
+ if (!event.mimeData.hasData(CONTENTS_MIME)) {
1296
+ return;
1297
+ }
1298
+
1299
+ let target = event.target as HTMLElement;
1300
+ while (target && target.parentElement) {
1301
+ if (target.classList.contains(DROP_TARGET_CLASS)) {
1302
+ target.classList.remove(DROP_TARGET_CLASS);
1303
+ break;
1304
+ }
1305
+ target = target.parentElement;
1306
+ }
1307
+
1308
+ // Get the path based on the target node.
1309
+ const index = ArrayExt.firstIndexOf(this._items, target);
1310
+ const items = this._sortedItems;
1311
+ let basePath = this._model.path;
1312
+ if (items[index].type === 'directory') {
1313
+ basePath = PathExt.join(basePath, items[index].name);
1314
+ }
1315
+ const manager = this._manager;
1316
+
1317
+ // Handle the items.
1318
+ const promises: Promise<Contents.IModel | null>[] = [];
1319
+ const paths = event.mimeData.getData(CONTENTS_MIME) as string[];
1320
+
1321
+ if (event.ctrlKey && event.proposedAction === 'move') {
1322
+ event.dropAction = 'copy';
1323
+ } else {
1324
+ event.dropAction = event.proposedAction;
1325
+ }
1326
+ for (const path of paths) {
1327
+ const localPath = manager.services.contents.localPath(path);
1328
+ const name = PathExt.basename(localPath);
1329
+ const newPath = PathExt.join(basePath, name);
1330
+ // Skip files that are not moving.
1331
+ if (newPath === path) {
1332
+ continue;
1333
+ }
1334
+
1335
+ if (event.dropAction === 'copy') {
1336
+ promises.push(manager.copy(path, basePath));
1337
+ } else {
1338
+ promises.push(renameFile(manager, path, newPath));
1339
+ }
1340
+ }
1341
+ Promise.all(promises).catch(error => {
1342
+ void showErrorMessage(
1343
+ this._trans._p('showErrorMessage', 'Error while copying/moving files'),
1344
+ error
1345
+ );
1346
+ });
1347
+ }
1348
+
1349
+ /**
1350
+ * Start a drag event.
1351
+ */
1352
+ private _startDrag(index: number, clientX: number, clientY: number): void {
1353
+ let selectedPaths = Object.keys(this.selection);
1354
+ const source = this._items[index];
1355
+ const items = this._sortedItems;
1356
+ let selectedItems: Iterable<Contents.IModel>;
1357
+ let item: Contents.IModel | undefined;
1358
+
1359
+ // If the source node is not selected, use just that node.
1360
+ if (!source.classList.contains(SELECTED_CLASS)) {
1361
+ item = items[index];
1362
+ selectedPaths = [item.path];
1363
+ selectedItems = [item];
1364
+ } else {
1365
+ const path = selectedPaths[0];
1366
+ item = items.find(value => value.path === path);
1367
+ selectedItems = this.selectedItems();
1368
+ }
1369
+
1370
+ if (!item) {
1371
+ return;
1372
+ }
1373
+
1374
+ // Create the drag image.
1375
+ const ft = this._manager.registry.getFileTypeForModel(item);
1376
+ const dragImage = this.renderer.createDragImage(
1377
+ source,
1378
+ selectedPaths.length,
1379
+ this._trans,
1380
+ ft
1381
+ );
1382
+
1383
+ // Set up the drag event.
1384
+ this._drag = new Drag({
1385
+ dragImage,
1386
+ mimeData: new MimeData(),
1387
+ supportedActions: 'move',
1388
+ proposedAction: 'move'
1389
+ });
1390
+
1391
+ this._drag.mimeData.setData(CONTENTS_MIME, selectedPaths);
1392
+
1393
+ // Add thunks for getting mime data content.
1394
+ // We thunk the content so we don't try to make a network call
1395
+ // when it's not needed. E.g. just moving files around
1396
+ // in a filebrowser
1397
+ const services = this.model.manager.services;
1398
+ for (const item of selectedItems) {
1399
+ this._drag.mimeData.setData(CONTENTS_MIME_RICH, {
1400
+ model: item,
1401
+ withContent: async () => {
1402
+ return await services.contents.get(item.path);
1403
+ }
1404
+ } as DirListing.IContentsThunk);
1405
+ }
1406
+
1407
+ if (item && item.type !== 'directory') {
1408
+ const otherPaths = selectedPaths.slice(1).reverse();
1409
+ this._drag.mimeData.setData(FACTORY_MIME, () => {
1410
+ if (!item) {
1411
+ return;
1412
+ }
1413
+ const path = item.path;
1414
+ let widget = this._manager.findWidget(path);
1415
+ if (!widget) {
1416
+ widget = this._manager.open(item.path);
1417
+ }
1418
+ if (otherPaths.length) {
1419
+ const firstWidgetPlaced = new PromiseDelegate<void>();
1420
+ void firstWidgetPlaced.promise.then(() => {
1421
+ let prevWidget = widget;
1422
+ otherPaths.forEach(path => {
1423
+ const options: DocumentRegistry.IOpenOptions = {
1424
+ ref: prevWidget?.id,
1425
+ mode: 'tab-after'
1426
+ };
1427
+ prevWidget = this._manager.openOrReveal(
1428
+ path,
1429
+ void 0,
1430
+ void 0,
1431
+ options
1432
+ );
1433
+ this._manager.openOrReveal(item!.path);
1434
+ });
1435
+ });
1436
+ firstWidgetPlaced.resolve(void 0);
1437
+ }
1438
+ return widget;
1439
+ });
1440
+ }
1441
+
1442
+ // Start the drag and remove the mousemove and mouseup listeners.
1443
+ document.removeEventListener('mousemove', this, true);
1444
+ document.removeEventListener('mouseup', this, true);
1445
+ clearTimeout(this._selectTimer);
1446
+ void this._drag.start(clientX, clientY).then(action => {
1447
+ this._drag = null;
1448
+ clearTimeout(this._selectTimer);
1449
+ });
1450
+ }
1451
+
1452
+ /**
1453
+ * Handle selection on a file node.
1454
+ */
1455
+ protected handleFileSelect(event: MouseEvent): void {
1456
+ // Fetch common variables.
1457
+ const items = this._sortedItems;
1458
+ const index = Private.hitTestNodes(this._items, event);
1459
+
1460
+ clearTimeout(this._selectTimer);
1461
+
1462
+ if (index === -1) {
1463
+ return;
1464
+ }
1465
+
1466
+ // Clear any existing soft selection.
1467
+ this._softSelection = '';
1468
+
1469
+ const path = items[index].path;
1470
+ const selected = Object.keys(this.selection);
1471
+
1472
+ const isLeftClickOnCheckbox =
1473
+ event.button === 0 &&
1474
+ // On Mac, a left-click with the ctrlKey is treated as a right-click.
1475
+ !(IS_MAC && event.ctrlKey) &&
1476
+ this.isWithinCheckboxHitArea(event);
1477
+
1478
+ // Handle toggling.
1479
+ if (
1480
+ (IS_MAC && event.metaKey) ||
1481
+ (!IS_MAC && event.ctrlKey) ||
1482
+ isLeftClickOnCheckbox
1483
+ ) {
1484
+ if (this.selection[path]) {
1485
+ delete this.selection[path];
1486
+ } else {
1487
+ this.selection[path] = true;
1488
+ }
1489
+
1490
+ // Handle multiple select.
1491
+ } else if (event.shiftKey) {
1492
+ this._handleMultiSelect(selected, index);
1493
+
1494
+ // Handle a 'soft' selection
1495
+ } else if (path in this.selection && selected.length > 1) {
1496
+ this._softSelection = path;
1497
+
1498
+ // Default to selecting the only the item.
1499
+ } else {
1500
+ // Select only the given item.
1501
+ return this._selectItem(index, false);
1502
+ }
1503
+ this.update();
1504
+ }
1505
+
1506
+ /**
1507
+ * (Re-)focus on the selected file.
1508
+ *
1509
+ * If index is not given, it will be inferred from the current selection;
1510
+ * providing index saves on the iteration time.
1511
+ */
1512
+ private _focusSelectedFile(index?: number): void {
1513
+ if (typeof index === 'undefined') {
1514
+ const selected = Object.keys(this.selection);
1515
+ if (selected.length > 1) {
1516
+ // Multiselect - do not focus on any single file
1517
+ return;
1518
+ }
1519
+ index = ArrayExt.findFirstIndex(
1520
+ this._sortedItems,
1521
+ value => value.path === selected[0]
1522
+ );
1523
+ }
1524
+ if (index === -1) {
1525
+ return;
1526
+ }
1527
+ // Focus on text to make shortcuts works
1528
+ const node = this._items[index];
1529
+ const text = DOMUtils.findElement(node, ITEM_TEXT_CLASS);
1530
+ if (text) {
1531
+ text.focus();
1532
+ }
1533
+ }
1534
+
1535
+ /**
1536
+ * Handle a multiple select on a file item node.
1537
+ */
1538
+ private _handleMultiSelect(selected: string[], index: number): void {
1539
+ // Find the "nearest selected".
1540
+ const items = this._sortedItems;
1541
+ let nearestIndex = -1;
1542
+ for (let i = 0; i < this._items.length; i++) {
1543
+ if (i === index) {
1544
+ continue;
1545
+ }
1546
+ const path = items[i].path;
1547
+ if (selected.indexOf(path) !== -1) {
1548
+ if (nearestIndex === -1) {
1549
+ nearestIndex = i;
1550
+ } else {
1551
+ if (Math.abs(index - i) < Math.abs(nearestIndex - i)) {
1552
+ nearestIndex = i;
1553
+ }
1554
+ }
1555
+ }
1556
+ }
1557
+
1558
+ // Default to the first element (and fill down).
1559
+ if (nearestIndex === -1) {
1560
+ nearestIndex = 0;
1561
+ }
1562
+
1563
+ // Select the rows between the current and the nearest selected.
1564
+ for (let i = 0; i < this._items.length; i++) {
1565
+ if (
1566
+ (nearestIndex >= i && index <= i) ||
1567
+ (nearestIndex <= i && index >= i)
1568
+ ) {
1569
+ this.selection[items[i].path] = true;
1570
+ }
1571
+ }
1572
+ }
1573
+
1574
+ /**
1575
+ * Copy the selected items, and optionally cut as well.
1576
+ */
1577
+ private _copy(): void {
1578
+ this._clipboard.length = 0;
1579
+ for (const item of this.selectedItems()) {
1580
+ this._clipboard.push(item.path);
1581
+ }
1582
+ }
1583
+
1584
+ /**
1585
+ * Delete the files with the given paths.
1586
+ */
1587
+ private async _delete(paths: string[]): Promise<void> {
1588
+ await Promise.all(
1589
+ paths.map(path =>
1590
+ this._model.manager.deleteFile(path).catch(err => {
1591
+ void showErrorMessage(
1592
+ this._trans._p('showErrorMessage', 'Delete Failed'),
1593
+ err
1594
+ );
1595
+ })
1596
+ )
1597
+ );
1598
+ }
1599
+
1600
+ /**
1601
+ * Allow the user to rename item on a given row.
1602
+ */
1603
+ private _doRename(): Promise<string> {
1604
+ this._inRename = true;
1605
+ const items = this._sortedItems;
1606
+ const path = Object.keys(this.selection)[0];
1607
+ const index = ArrayExt.findFirstIndex(items, value => value.path === path);
1608
+ const row = this._items[index];
1609
+ const item = items[index];
1610
+ const nameNode = this.renderer.getNameNode(row);
1611
+ const original = item.name;
1612
+ this._editNode.value = original;
1613
+ this._selectItem(index, false);
1614
+
1615
+ return Private.doRename(nameNode, this._editNode, original).then(
1616
+ newName => {
1617
+ this.node.focus();
1618
+ if (!newName || newName === original) {
1619
+ this._inRename = false;
1620
+ return original;
1621
+ }
1622
+ if (!isValidFileName(newName)) {
1623
+ void showErrorMessage(
1624
+ this._trans.__('Rename Error'),
1625
+ Error(
1626
+ this._trans._p(
1627
+ 'showErrorMessage',
1628
+ '"%1" is not a valid name for a file. Names must have nonzero length, and cannot include "/", "\\", or ":"',
1629
+ newName
1630
+ )
1631
+ )
1632
+ );
1633
+ this._inRename = false;
1634
+ return original;
1635
+ }
1636
+
1637
+ if (this.isDisposed) {
1638
+ this._inRename = false;
1639
+ throw new Error('File browser is disposed.');
1640
+ }
1641
+
1642
+ const manager = this._manager;
1643
+ const oldPath = PathExt.join(this._model.path, original);
1644
+ const newPath = PathExt.join(this._model.path, newName);
1645
+ const promise = renameFile(manager, oldPath, newPath);
1646
+ return promise
1647
+ .catch(error => {
1648
+ if (error !== 'File not renamed') {
1649
+ void showErrorMessage(
1650
+ this._trans._p('showErrorMessage', 'Rename Error'),
1651
+ error
1652
+ );
1653
+ }
1654
+ this._inRename = false;
1655
+ return original;
1656
+ })
1657
+ .then(() => {
1658
+ if (this.isDisposed) {
1659
+ this._inRename = false;
1660
+ throw new Error('File browser is disposed.');
1661
+ }
1662
+ if (this._inRename) {
1663
+ // No need to catch because `newName` will always exit.
1664
+ void this.selectItemByName(newName);
1665
+ }
1666
+ this._inRename = false;
1667
+ return newName;
1668
+ });
1669
+ }
1670
+ );
1671
+ }
1672
+
1673
+ /**
1674
+ * Select a given item.
1675
+ */
1676
+ private _selectItem(
1677
+ index: number,
1678
+ keepExisting: boolean,
1679
+ focus: boolean = true
1680
+ ) {
1681
+ // Selected the given row(s)
1682
+ const items = this._sortedItems;
1683
+ if (!keepExisting) {
1684
+ this.clearSelectedItems();
1685
+ }
1686
+ const path = items[index].path;
1687
+ this.selection[path] = true;
1688
+
1689
+ if (!keepExisting && focus) {
1690
+ this._focusSelectedFile(index);
1691
+ }
1692
+ this.update();
1693
+ }
1694
+
1695
+ /**
1696
+ * Handle the `refreshed` signal from the model.
1697
+ */
1698
+ private _onModelRefreshed(): void {
1699
+ // Update the selection.
1700
+ const existing = Object.keys(this.selection);
1701
+ this.clearSelectedItems();
1702
+ for (const item of this._model.items()) {
1703
+ const path = item.path;
1704
+ if (existing.indexOf(path) !== -1) {
1705
+ this.selection[path] = true;
1706
+ }
1707
+ }
1708
+ if (this.isVisible) {
1709
+ // Update the sorted items.
1710
+ this.sort(this.sortState);
1711
+ } else {
1712
+ this._isDirty = true;
1713
+ }
1714
+ }
1715
+
1716
+ /**
1717
+ * Handle a `pathChanged` signal from the model.
1718
+ */
1719
+ private _onPathChanged(): void {
1720
+ // Reset the selection.
1721
+ this.clearSelectedItems();
1722
+ // Update the sorted items.
1723
+ this.sort(this.sortState);
1724
+ }
1725
+
1726
+ /**
1727
+ * Handle a `fileChanged` signal from the model.
1728
+ */
1729
+ private _onFileChanged(
1730
+ sender: FilterFileBrowserModel,
1731
+ args: Contents.IChangedArgs
1732
+ ) {
1733
+ const newValue = args.newValue;
1734
+ if (!newValue) {
1735
+ return;
1736
+ }
1737
+
1738
+ const name = newValue.name;
1739
+ if (args.type !== 'new' || !name) {
1740
+ return;
1741
+ }
1742
+
1743
+ void this.selectItemByName(name).catch(() => {
1744
+ /* Ignore if file does not exist. */
1745
+ });
1746
+ }
1747
+
1748
+ /**
1749
+ * Handle an `activateRequested` signal from the manager.
1750
+ */
1751
+ private _onActivateRequested(sender: IDocumentManager, args: string): void {
1752
+ const dirname = PathExt.dirname(args);
1753
+ if (dirname !== this._model.path) {
1754
+ return;
1755
+ }
1756
+ const basename = PathExt.basename(args);
1757
+ this.selectItemByName(basename).catch(() => {
1758
+ /* Ignore if file does not exist. */
1759
+ });
1760
+ }
1761
+
1762
+ protected translator: ITranslator;
1763
+ protected _model: FilterFileBrowserModel;
1764
+ private _trans: TranslationBundle;
1765
+ private _editNode: HTMLInputElement;
1766
+ private _items: HTMLElement[] = [];
1767
+ private _sortedItems: Contents.IModel[] = [];
1768
+ private _sortState: DirListing.ISortState = {
1769
+ direction: 'ascending',
1770
+ key: 'name'
1771
+ };
1772
+ private _onItemOpened = new Signal<DirListing, Contents.IModel>(this);
1773
+ private _drag: Drag | null = null;
1774
+ private _dragData: {
1775
+ pressX: number;
1776
+ pressY: number;
1777
+ index: number;
1778
+ } | null = null;
1779
+ private _selectTimer = -1;
1780
+ private _isCut = false;
1781
+ private _prevPath = '';
1782
+ private _clipboard: string[] = [];
1783
+ private _manager: IDocumentManager;
1784
+ private _softSelection = '';
1785
+ protected selection: { [key: string]: boolean } = Object.create(null);
1786
+ private _renderer: DirListing.IRenderer;
1787
+ private _searchPrefix: string = '';
1788
+ private _searchPrefixTimer = -1;
1789
+ private _inRename = false;
1790
+ private _isDirty = false;
1791
+ private _hiddenColumns = new Set<DirListing.ToggleableColumn>();
1792
+ }
1793
+
1794
+ /**
1795
+ * The namespace for the `DirListing` class statics.
1796
+ */
1797
+ export namespace DirListing {
1798
+ /**
1799
+ * An options object for initializing a file browser directory listing.
1800
+ */
1801
+ export interface IOptions {
1802
+ /**
1803
+ * A file browser model instance.
1804
+ */
1805
+ model: FilterFileBrowserModel;
1806
+
1807
+ /**
1808
+ * A renderer for file items.
1809
+ *
1810
+ * The default is a shared `Renderer` instance.
1811
+ */
1812
+ renderer?: IRenderer;
1813
+
1814
+ /**
1815
+ * A language translator.
1816
+ */
1817
+ translator?: ITranslator;
1818
+ }
1819
+
1820
+ /**
1821
+ * A sort state.
1822
+ */
1823
+ export interface ISortState {
1824
+ /**
1825
+ * The direction of sort.
1826
+ */
1827
+ direction: 'ascending' | 'descending';
1828
+
1829
+ /**
1830
+ * The sort key.
1831
+ */
1832
+ key: 'name' | 'last_modified' | 'file_size';
1833
+ }
1834
+
1835
+ /**
1836
+ * Toggleable columns.
1837
+ */
1838
+ export type ToggleableColumn = 'last_modified' | 'is_selected' | 'file_size';
1839
+
1840
+ /**
1841
+ * A file contents model thunk.
1842
+ *
1843
+ * Note: The content of the model will be empty.
1844
+ * To get the contents, call and await the `withContent`
1845
+ * method.
1846
+ */
1847
+ export interface IContentsThunk {
1848
+ /**
1849
+ * The contents model.
1850
+ */
1851
+ model: Contents.IModel;
1852
+
1853
+ /**
1854
+ * Fetches the model with contents.
1855
+ */
1856
+ withContent: () => Promise<Contents.IModel>;
1857
+ }
1858
+
1859
+ /**
1860
+ * The render interface for file browser listing options.
1861
+ */
1862
+ export interface IRenderer {
1863
+ /**
1864
+ * Create the DOM node for a dir listing.
1865
+ */
1866
+ createNode(): HTMLElement;
1867
+
1868
+ /**
1869
+ * Populate and empty header node for a dir listing.
1870
+ *
1871
+ * @param node - The header node to populate.
1872
+ */
1873
+ populateHeaderNode(
1874
+ node: HTMLElement,
1875
+ translator?: ITranslator,
1876
+ hiddenColumns?: Set<DirListing.ToggleableColumn>
1877
+ ): void;
1878
+
1879
+ /**
1880
+ * Handle a header click.
1881
+ *
1882
+ * @param node - A node populated by [[populateHeaderNode]].
1883
+ *
1884
+ * @param event - A click event on the node.
1885
+ *
1886
+ * @returns The sort state of the header after the click event.
1887
+ */
1888
+ handleHeaderClick(node: HTMLElement, event: MouseEvent): ISortState | null;
1889
+
1890
+ /**
1891
+ * Create a new item node for a dir listing.
1892
+ *
1893
+ * @returns A new DOM node to use as a content item.
1894
+ */
1895
+ createItemNode(
1896
+ hiddenColumns?: Set<DirListing.ToggleableColumn>
1897
+ ): HTMLElement;
1898
+
1899
+ /**
1900
+ * Update an item node to reflect the current state of a model.
1901
+ *
1902
+ * @param node - A node created by [[createItemNode]].
1903
+ *
1904
+ * @param model - The model object to use for the item state.
1905
+ *
1906
+ * @param fileType - The file type of the item, if applicable.
1907
+ */
1908
+ updateItemNode(
1909
+ node: HTMLElement,
1910
+ model: Contents.IModel,
1911
+ fileType?: DocumentRegistry.IFileType,
1912
+ translator?: ITranslator,
1913
+ hiddenColumns?: Set<DirListing.ToggleableColumn>,
1914
+ selected?: boolean
1915
+ ): void;
1916
+
1917
+ /**
1918
+ * Get the node containing the file name.
1919
+ *
1920
+ * @param node - A node created by [[createItemNode]].
1921
+ *
1922
+ * @returns The node containing the file name.
1923
+ */
1924
+ getNameNode(node: HTMLElement): HTMLElement;
1925
+
1926
+ /**
1927
+ * Get the checkbox input element node.
1928
+ *
1929
+ * Downstream interface implementations,such as jupyterlab-unfold, that
1930
+ * don't support checkboxes should simply always return null for this
1931
+ * function.
1932
+ *
1933
+ * @param node A node created by [[createItemNode]] or
1934
+ * [[createHeaderItemNode]]
1935
+ *
1936
+ * @returns The checkbox node.
1937
+ */
1938
+ getCheckboxNode: (node: HTMLElement) => HTMLInputElement | null;
1939
+
1940
+ /**
1941
+ * Create an appropriate drag image for an item.
1942
+ *
1943
+ * @param node - A node created by [[createItemNode]].
1944
+ *
1945
+ * @param count - The number of items being dragged.
1946
+ *
1947
+ * @param fileType - The file type of the item, if applicable.
1948
+ *
1949
+ * @returns An element to use as the drag image.
1950
+ */
1951
+ createDragImage(
1952
+ node: HTMLElement,
1953
+ count: number,
1954
+ trans: TranslationBundle,
1955
+ fileType?: DocumentRegistry.IFileType
1956
+ ): HTMLElement;
1957
+ }
1958
+
1959
+ /**
1960
+ * The default implementation of an `IRenderer`.
1961
+ */
1962
+ export class Renderer implements IRenderer {
1963
+ /**
1964
+ * Create the DOM node for a dir listing.
1965
+ */
1966
+ createNode(): HTMLElement {
1967
+ const node = document.createElement('div');
1968
+ const header = document.createElement('div');
1969
+ const content = document.createElement('ul');
1970
+ content.className = CONTENT_CLASS;
1971
+ header.className = HEADER_CLASS;
1972
+ node.appendChild(header);
1973
+ node.appendChild(content);
1974
+ node.tabIndex = 0;
1975
+ return node;
1976
+ }
1977
+
1978
+ /**
1979
+ * Populate and empty header node for a dir listing.
1980
+ *
1981
+ * @param node - The header node to populate.
1982
+ */
1983
+ populateHeaderNode(
1984
+ node: HTMLElement,
1985
+ translator?: ITranslator,
1986
+ hiddenColumns?: Set<DirListing.ToggleableColumn>
1987
+ ): void {
1988
+ translator = translator || nullTranslator;
1989
+ const trans = translator.load('jupyterlab');
1990
+ const name = this.createHeaderItemNode(trans.__('Name'));
1991
+ const narrow = document.createElement('div');
1992
+ const modified = this.createHeaderItemNode(trans.__('Last Modified'));
1993
+ const fileSize = this.createHeaderItemNode(trans.__('File Size'));
1994
+ name.classList.add(NAME_ID_CLASS);
1995
+ name.classList.add(SELECTED_CLASS);
1996
+ modified.classList.add(MODIFIED_ID_CLASS);
1997
+ fileSize.classList.add(FILE_SIZE_ID_CLASS);
1998
+ narrow.classList.add(NARROW_ID_CLASS);
1999
+ narrow.textContent = '...';
2000
+ if (!hiddenColumns?.has('is_selected')) {
2001
+ const checkboxWrapper = this.createCheckboxWrapperNode({
2002
+ alwaysVisible: true
2003
+ });
2004
+ node.appendChild(checkboxWrapper);
2005
+ }
2006
+ node.appendChild(name);
2007
+ node.appendChild(narrow);
2008
+ node.appendChild(modified);
2009
+ node.appendChild(fileSize);
2010
+
2011
+ if (hiddenColumns?.has('last_modified')) {
2012
+ modified.classList.add(MODIFIED_COLUMN_HIDDEN);
2013
+ } else {
2014
+ modified.classList.remove(MODIFIED_COLUMN_HIDDEN);
2015
+ }
2016
+
2017
+ if (hiddenColumns?.has('file_size')) {
2018
+ fileSize.classList.add(FILE_SIZE_COLUMN_HIDDEN);
2019
+ } else {
2020
+ fileSize.classList.remove(FILE_SIZE_COLUMN_HIDDEN);
2021
+ }
2022
+
2023
+ // set the initial caret icon
2024
+ Private.updateCaret(
2025
+ DOMUtils.findElement(name, HEADER_ITEM_ICON_CLASS),
2026
+ 'right',
2027
+ 'up'
2028
+ );
2029
+ }
2030
+
2031
+ /**
2032
+ * Handle a header click.
2033
+ *
2034
+ * @param node - A node populated by [[populateHeaderNode]].
2035
+ *
2036
+ * @param event - A click event on the node.
2037
+ *
2038
+ * @returns The sort state of the header after the click event.
2039
+ */
2040
+ handleHeaderClick(node: HTMLElement, event: MouseEvent): ISortState | null {
2041
+ const name = DOMUtils.findElement(node, NAME_ID_CLASS);
2042
+ const modified = DOMUtils.findElement(node, MODIFIED_ID_CLASS);
2043
+ const fileSize = DOMUtils.findElement(node, FILE_SIZE_ID_CLASS);
2044
+ const state: ISortState = { direction: 'ascending', key: 'name' };
2045
+ const target = event.target as HTMLElement;
2046
+
2047
+ const modifiedIcon = DOMUtils.findElement(
2048
+ modified,
2049
+ HEADER_ITEM_ICON_CLASS
2050
+ );
2051
+ const fileSizeIcon = DOMUtils.findElement(
2052
+ fileSize,
2053
+ HEADER_ITEM_ICON_CLASS
2054
+ );
2055
+ const nameIcon = DOMUtils.findElement(name, HEADER_ITEM_ICON_CLASS);
2056
+
2057
+ if (name.contains(target)) {
2058
+ if (name.classList.contains(SELECTED_CLASS)) {
2059
+ if (!name.classList.contains(DESCENDING_CLASS)) {
2060
+ state.direction = 'descending';
2061
+ name.classList.add(DESCENDING_CLASS);
2062
+ Private.updateCaret(nameIcon, 'right', 'down');
2063
+ } else {
2064
+ name.classList.remove(DESCENDING_CLASS);
2065
+ Private.updateCaret(nameIcon, 'right', 'up');
2066
+ }
2067
+ } else {
2068
+ name.classList.remove(DESCENDING_CLASS);
2069
+ Private.updateCaret(nameIcon, 'right', 'up');
2070
+ }
2071
+ name.classList.add(SELECTED_CLASS);
2072
+ modified.classList.remove(SELECTED_CLASS);
2073
+ modified.classList.remove(DESCENDING_CLASS);
2074
+ fileSize.classList.remove(SELECTED_CLASS);
2075
+ fileSize.classList.remove(DESCENDING_CLASS);
2076
+ Private.updateCaret(modifiedIcon, 'left');
2077
+ Private.updateCaret(fileSizeIcon, 'left');
2078
+ return state;
2079
+ }
2080
+ if (modified.contains(target)) {
2081
+ state.key = 'last_modified';
2082
+ if (modified.classList.contains(SELECTED_CLASS)) {
2083
+ if (!modified.classList.contains(DESCENDING_CLASS)) {
2084
+ state.direction = 'descending';
2085
+ modified.classList.add(DESCENDING_CLASS);
2086
+ Private.updateCaret(modifiedIcon, 'left', 'down');
2087
+ } else {
2088
+ modified.classList.remove(DESCENDING_CLASS);
2089
+ Private.updateCaret(modifiedIcon, 'left', 'up');
2090
+ }
2091
+ } else {
2092
+ modified.classList.remove(DESCENDING_CLASS);
2093
+ Private.updateCaret(modifiedIcon, 'left', 'up');
2094
+ }
2095
+ modified.classList.add(SELECTED_CLASS);
2096
+ name.classList.remove(SELECTED_CLASS);
2097
+ name.classList.remove(DESCENDING_CLASS);
2098
+ fileSize.classList.remove(SELECTED_CLASS);
2099
+ fileSize.classList.remove(DESCENDING_CLASS);
2100
+ Private.updateCaret(nameIcon, 'right');
2101
+ Private.updateCaret(fileSizeIcon, 'left');
2102
+ return state;
2103
+ }
2104
+ if (fileSize.contains(target)) {
2105
+ state.key = 'file_size';
2106
+ if (fileSize.classList.contains(SELECTED_CLASS)) {
2107
+ if (!fileSize.classList.contains(DESCENDING_CLASS)) {
2108
+ state.direction = 'descending';
2109
+ fileSize.classList.add(DESCENDING_CLASS);
2110
+ Private.updateCaret(fileSizeIcon, 'left', 'down');
2111
+ } else {
2112
+ fileSize.classList.remove(DESCENDING_CLASS);
2113
+ Private.updateCaret(fileSizeIcon, 'left', 'up');
2114
+ }
2115
+ } else {
2116
+ fileSize.classList.remove(DESCENDING_CLASS);
2117
+ Private.updateCaret(fileSizeIcon, 'left', 'up');
2118
+ }
2119
+ fileSize.classList.add(SELECTED_CLASS);
2120
+ name.classList.remove(SELECTED_CLASS);
2121
+ name.classList.remove(DESCENDING_CLASS);
2122
+ modified.classList.remove(SELECTED_CLASS);
2123
+ modified.classList.remove(DESCENDING_CLASS);
2124
+ Private.updateCaret(nameIcon, 'right');
2125
+ Private.updateCaret(modifiedIcon, 'left');
2126
+ return state;
2127
+ }
2128
+ return state;
2129
+ }
2130
+
2131
+ /**
2132
+ * Create a new item node for a dir listing.
2133
+ *
2134
+ * @returns A new DOM node to use as a content item.
2135
+ */
2136
+ createItemNode(
2137
+ hiddenColumns?: Set<DirListing.ToggleableColumn>
2138
+ ): HTMLElement {
2139
+ const node = document.createElement('li');
2140
+ const icon = document.createElement('span');
2141
+ const text = document.createElement('span');
2142
+ const modified = document.createElement('span');
2143
+ const fileSize = document.createElement('span');
2144
+ icon.className = ITEM_ICON_CLASS;
2145
+ text.className = ITEM_TEXT_CLASS;
2146
+ modified.className = ITEM_MODIFIED_CLASS;
2147
+ fileSize.className = ITEM_FILE_SIZE_CLASS;
2148
+ if (!hiddenColumns?.has('is_selected')) {
2149
+ const checkboxWrapper = this.createCheckboxWrapperNode();
2150
+ node.appendChild(checkboxWrapper);
2151
+ }
2152
+ node.appendChild(icon);
2153
+ node.appendChild(text);
2154
+ node.appendChild(modified);
2155
+ node.appendChild(fileSize);
2156
+
2157
+ // Make the text note focusable so that it receives keyboard events;
2158
+ // text node was specifically chosen to receive shortcuts because
2159
+ // text element gets substituted with input area during file name edits
2160
+ // which conveniently deactivate irrelevant shortcuts.
2161
+ text.tabIndex = 0;
2162
+
2163
+ if (hiddenColumns?.has('last_modified')) {
2164
+ modified.classList.add(MODIFIED_COLUMN_HIDDEN);
2165
+ } else {
2166
+ modified.classList.remove(MODIFIED_COLUMN_HIDDEN);
2167
+ }
2168
+
2169
+ if (hiddenColumns?.has('file_size')) {
2170
+ fileSize.classList.add(FILE_SIZE_COLUMN_HIDDEN);
2171
+ } else {
2172
+ fileSize.classList.remove(FILE_SIZE_COLUMN_HIDDEN);
2173
+ }
2174
+
2175
+ return node;
2176
+ }
2177
+
2178
+ /**
2179
+ * Creates a node containing a checkbox.
2180
+ *
2181
+ * We wrap the checkbox in a label element in order to increase its hit
2182
+ * area. This is because the padding of the checkbox itself cannot be
2183
+ * increased via CSS, as the CSS/form compatibility table at the following
2184
+ * url from MDN shows:
2185
+ * https://developer.mozilla.org/en-US/docs/Learn/Forms/Property_compatibility_table_for_form_controls#check_boxes_and_radio_buttons
2186
+ *
2187
+ * @param [options]
2188
+ * @params options.alwaysVisible Should the checkbox be visible even when
2189
+ * not hovered?
2190
+ * @returns A new DOM node that contains a checkbox.
2191
+ */
2192
+ createCheckboxWrapperNode(options?: {
2193
+ alwaysVisible: boolean;
2194
+ }): HTMLElement {
2195
+ // Wrap the checkbox in a label element in order to increase its hit area.
2196
+ const labelWrapper = document.createElement('label');
2197
+ labelWrapper.classList.add(CHECKBOX_WRAPPER_CLASS);
2198
+ // The individual file checkboxes are visible on hover, but the header
2199
+ // check-all checkbox is always visible.
2200
+ if (options?.alwaysVisible) {
2201
+ labelWrapper.classList.add('jp-mod-visible');
2202
+ }
2203
+ const checkbox = document.createElement('input');
2204
+ checkbox.type = 'checkbox';
2205
+ // Prevent the user from clicking (via mouse, keyboard, or touch) the
2206
+ // checkbox since other code handles the mouse and keyboard events and
2207
+ // controls the checked state of the checkbox.
2208
+ checkbox.addEventListener('click', event => {
2209
+ event.preventDefault();
2210
+ });
2211
+ labelWrapper.appendChild(checkbox);
2212
+ return labelWrapper;
2213
+ }
2214
+
2215
+ /**
2216
+ * Update an item node to reflect the current state of a model.
2217
+ *
2218
+ * @param node - A node created by [[createItemNode]].
2219
+ *
2220
+ * @param model - The model object to use for the item state.
2221
+ *
2222
+ * @param fileType - The file type of the item, if applicable.
2223
+ *
2224
+ */
2225
+ updateItemNode(
2226
+ node: HTMLElement,
2227
+ model: Contents.IModel,
2228
+ fileType?: DocumentRegistry.IFileType,
2229
+ translator?: ITranslator,
2230
+ hiddenColumns?: Set<DirListing.ToggleableColumn>,
2231
+ selected?: boolean
2232
+ ): void {
2233
+ if (selected) {
2234
+ node.classList.add(SELECTED_CLASS);
2235
+ }
2236
+
2237
+ fileType =
2238
+ fileType || DocumentRegistry.getDefaultTextFileType(translator);
2239
+ const { icon, iconClass, name } = fileType;
2240
+ translator = translator || nullTranslator;
2241
+ const trans = translator.load('jupyterlab');
2242
+
2243
+ const iconContainer = DOMUtils.findElement(node, ITEM_ICON_CLASS);
2244
+ const text = DOMUtils.findElement(node, ITEM_TEXT_CLASS);
2245
+ const modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS);
2246
+ const fileSize = DOMUtils.findElement(node, ITEM_FILE_SIZE_CLASS);
2247
+ const checkboxWrapper = DOMUtils.findElement(
2248
+ node,
2249
+ CHECKBOX_WRAPPER_CLASS
2250
+ );
2251
+
2252
+ const showFileCheckboxes = !hiddenColumns?.has('is_selected');
2253
+ if (checkboxWrapper && !showFileCheckboxes) {
2254
+ node.removeChild(checkboxWrapper);
2255
+ } else if (showFileCheckboxes && !checkboxWrapper) {
2256
+ const checkboxWrapper = this.createCheckboxWrapperNode();
2257
+ node.insertBefore(checkboxWrapper, iconContainer);
2258
+ }
2259
+
2260
+ if (hiddenColumns?.has('last_modified')) {
2261
+ modified.classList.add(MODIFIED_COLUMN_HIDDEN);
2262
+ } else {
2263
+ modified.classList.remove(MODIFIED_COLUMN_HIDDEN);
2264
+ }
2265
+
2266
+ if (hiddenColumns?.has('file_size')) {
2267
+ fileSize.classList.add(FILE_SIZE_COLUMN_HIDDEN);
2268
+ } else {
2269
+ fileSize.classList.remove(FILE_SIZE_COLUMN_HIDDEN);
2270
+ }
2271
+
2272
+ // render the file item's icon
2273
+ LabIcon.resolveElement({
2274
+ icon,
2275
+ iconClass: classes(iconClass, 'jp-Icon'),
2276
+ container: iconContainer,
2277
+ className: ITEM_ICON_CLASS,
2278
+ stylesheet: 'listing'
2279
+ });
2280
+
2281
+ let hoverText = trans.__('Name: %1', model.name);
2282
+
2283
+ // add file size to pop up if its available
2284
+ if (model.size !== null && model.size !== undefined) {
2285
+ const fileSizeText = Private.formatFileSize(model.size, 1, 1024);
2286
+ fileSize.textContent = fileSizeText;
2287
+ hoverText += trans.__(
2288
+ '\nSize: %1',
2289
+ Private.formatFileSize(model.size, 1, 1024)
2290
+ );
2291
+ } else {
2292
+ fileSize.textContent = '';
2293
+ }
2294
+ if (model.path) {
2295
+ const dirname = PathExt.dirname(model.path);
2296
+ if (dirname) {
2297
+ hoverText += trans.__('\nPath: %1', dirname.substr(0, 50));
2298
+ if (dirname.length > 50) {
2299
+ hoverText += '...';
2300
+ }
2301
+ }
2302
+ }
2303
+ if (model.created) {
2304
+ hoverText += trans.__(
2305
+ '\nCreated: %1',
2306
+ Time.format(new Date(model.created))
2307
+ );
2308
+ }
2309
+ if (model.last_modified) {
2310
+ hoverText += trans.__(
2311
+ '\nModified: %1',
2312
+ Time.format(new Date(model.last_modified))
2313
+ );
2314
+ }
2315
+ hoverText += trans.__('\nWritable: %1', model.writable);
2316
+
2317
+ node.title = hoverText;
2318
+ node.setAttribute('data-file-type', name);
2319
+ if (model.name.startsWith('.')) {
2320
+ node.setAttribute('data-is-dot', 'true');
2321
+ } else {
2322
+ node.removeAttribute('data-is-dot');
2323
+ }
2324
+ // If an item is being edited currently, its text node is unavailable.
2325
+ const indices = !model.indices ? [] : model.indices;
2326
+ let highlightedName = StringExt.highlight(model.name, indices, h.mark);
2327
+ if (text) {
2328
+ VirtualDOM.render(h.span(highlightedName), text);
2329
+ }
2330
+
2331
+ // Adds an aria-label to the checkbox element.
2332
+ const checkbox = checkboxWrapper?.querySelector(
2333
+ 'input[type="checkbox"]'
2334
+ ) as HTMLInputElement;
2335
+
2336
+ if (checkbox) {
2337
+ let ariaLabel: string;
2338
+ if (fileType.contentType === 'directory') {
2339
+ ariaLabel = selected
2340
+ ? trans.__('Deselect directory "%1"', highlightedName)
2341
+ : trans.__('Select directory "%1"', highlightedName);
2342
+ } else {
2343
+ ariaLabel = selected
2344
+ ? trans.__('Deselect file "%1"', highlightedName)
2345
+ : trans.__('Select file "%1"', highlightedName);
2346
+ }
2347
+ checkbox.setAttribute('aria-label', ariaLabel);
2348
+ checkbox.checked = selected ?? false;
2349
+ }
2350
+
2351
+ let modText = '';
2352
+ let modTitle = '';
2353
+ if (model.last_modified) {
2354
+ modText = Time.formatHuman(new Date(model.last_modified));
2355
+ modTitle = Time.format(new Date(model.last_modified));
2356
+ }
2357
+ modified.textContent = modText;
2358
+ modified.title = modTitle;
2359
+ }
2360
+
2361
+ /**
2362
+ * Get the node containing the file name.
2363
+ *
2364
+ * @param node - A node created by [[createItemNode]].
2365
+ *
2366
+ * @returns The node containing the file name.
2367
+ */
2368
+ getNameNode(node: HTMLElement): HTMLElement {
2369
+ return DOMUtils.findElement(node, ITEM_TEXT_CLASS);
2370
+ }
2371
+
2372
+ /**
2373
+ * Get the checkbox input element node.
2374
+ *
2375
+ * @param node A node created by [[createItemNode]] or
2376
+ * [[createHeaderItemNode]]
2377
+ *
2378
+ * @returns The checkbox node.
2379
+ */
2380
+ getCheckboxNode(node: HTMLElement): HTMLInputElement | null {
2381
+ return node.querySelector(
2382
+ `.${CHECKBOX_WRAPPER_CLASS} input[type=checkbox]`
2383
+ );
2384
+ }
2385
+
2386
+ /**
2387
+ * Create a drag image for an item.
2388
+ *
2389
+ * @param node - A node created by [[createItemNode]].
2390
+ *
2391
+ * @param count - The number of items being dragged.
2392
+ *
2393
+ * @param fileType - The file type of the item, if applicable.
2394
+ *
2395
+ * @returns An element to use as the drag image.
2396
+ */
2397
+ createDragImage(
2398
+ node: HTMLElement,
2399
+ count: number,
2400
+ trans: TranslationBundle,
2401
+ fileType?: DocumentRegistry.IFileType
2402
+ ): HTMLElement {
2403
+ const dragImage = node.cloneNode(true) as HTMLElement;
2404
+ const modified = DOMUtils.findElement(dragImage, ITEM_MODIFIED_CLASS);
2405
+ const icon = DOMUtils.findElement(dragImage, ITEM_ICON_CLASS);
2406
+ dragImage.removeChild(modified as HTMLElement);
2407
+
2408
+ if (!fileType) {
2409
+ icon.textContent = '';
2410
+ icon.className = '';
2411
+ } else {
2412
+ icon.textContent = fileType.iconLabel || '';
2413
+ icon.className = fileType.iconClass || '';
2414
+ }
2415
+ icon.classList.add(DRAG_ICON_CLASS);
2416
+
2417
+ if (count > 1) {
2418
+ const nameNode = DOMUtils.findElement(dragImage, ITEM_TEXT_CLASS);
2419
+ nameNode.textContent = trans._n('%1 Item', '%1 Items', count);
2420
+ }
2421
+ return dragImage;
2422
+ }
2423
+
2424
+ /**
2425
+ * Create a node for a header item.
2426
+ */
2427
+ protected createHeaderItemNode(label: string): HTMLElement {
2428
+ const node = document.createElement('div');
2429
+ const text = document.createElement('span');
2430
+ const icon = document.createElement('span');
2431
+ node.className = HEADER_ITEM_CLASS;
2432
+ text.className = HEADER_ITEM_TEXT_CLASS;
2433
+ icon.className = HEADER_ITEM_ICON_CLASS;
2434
+ text.textContent = label;
2435
+ node.appendChild(text);
2436
+ node.appendChild(icon);
2437
+ return node;
2438
+ }
2439
+ }
2440
+
2441
+ /**
2442
+ * The default `IRenderer` instance.
2443
+ */
2444
+ export const defaultRenderer = new Renderer();
2445
+ }
2446
+
2447
+ /**
2448
+ * The namespace for the listing private data.
2449
+ */
2450
+ namespace Private {
2451
+ /**
2452
+ * Handle editing text on a node.
2453
+ *
2454
+ * @returns Boolean indicating whether the name changed.
2455
+ */
2456
+ export function doRename(
2457
+ text: HTMLElement,
2458
+ edit: HTMLInputElement,
2459
+ original: string
2460
+ ): Promise<string> {
2461
+ const parent = text.parentElement as HTMLElement;
2462
+ parent.replaceChild(edit, text);
2463
+ edit.focus();
2464
+ const index = edit.value.lastIndexOf('.');
2465
+ if (index === -1) {
2466
+ edit.setSelectionRange(0, edit.value.length);
2467
+ } else {
2468
+ edit.setSelectionRange(0, index);
2469
+ }
2470
+
2471
+ return new Promise<string>((resolve, reject) => {
2472
+ edit.onblur = () => {
2473
+ parent.replaceChild(text, edit);
2474
+ resolve(edit.value);
2475
+ };
2476
+ edit.onkeydown = (event: KeyboardEvent) => {
2477
+ switch (event.keyCode) {
2478
+ case 13: // Enter
2479
+ event.stopPropagation();
2480
+ event.preventDefault();
2481
+ edit.blur();
2482
+ break;
2483
+ case 27: // Escape
2484
+ event.stopPropagation();
2485
+ event.preventDefault();
2486
+ edit.value = original;
2487
+ edit.blur();
2488
+ break;
2489
+ case 38: // Up arrow
2490
+ event.stopPropagation();
2491
+ event.preventDefault();
2492
+ if (edit.selectionStart !== edit.selectionEnd) {
2493
+ edit.selectionStart = edit.selectionEnd = 0;
2494
+ }
2495
+ break;
2496
+ case 40: // Down arrow
2497
+ event.stopPropagation();
2498
+ event.preventDefault();
2499
+ if (edit.selectionStart !== edit.selectionEnd) {
2500
+ edit.selectionStart = edit.selectionEnd = edit.value.length;
2501
+ }
2502
+ break;
2503
+ default:
2504
+ break;
2505
+ }
2506
+ };
2507
+ });
2508
+ }
2509
+
2510
+ /**
2511
+ * Sort a list of items by sort state as a new array.
2512
+ */
2513
+ export function sort(
2514
+ items: Iterable<Contents.IModel>,
2515
+ state: DirListing.ISortState
2516
+ ): Contents.IModel[] {
2517
+ const copy = Array.from(items);
2518
+ const reverse = state.direction === 'descending' ? 1 : -1;
2519
+
2520
+ if (state.key === 'last_modified') {
2521
+ // Sort by last modified (grouping directories first)
2522
+ copy.sort((a, b) => {
2523
+ const t1 = a.type === 'directory' ? 0 : 1;
2524
+ const t2 = b.type === 'directory' ? 0 : 1;
2525
+
2526
+ const valA = new Date(a.last_modified).getTime();
2527
+ const valB = new Date(b.last_modified).getTime();
2528
+
2529
+ return t1 - t2 || (valA - valB) * reverse;
2530
+ });
2531
+ } else if (state.key === 'file_size') {
2532
+ // Sort by size (grouping directories first)
2533
+ copy.sort((a, b) => {
2534
+ const t1 = a.type === 'directory' ? 0 : 1;
2535
+ const t2 = b.type === 'directory' ? 0 : 1;
2536
+
2537
+ return t1 - t2 || ((a.size ?? 0) - (b.size ?? 0)) * reverse;
2538
+ });
2539
+ } else {
2540
+ // Sort by name (grouping directories first)
2541
+ copy.sort((a, b) => {
2542
+ const t1 = a.type === 'directory' ? 0 : 1;
2543
+ const t2 = b.type === 'directory' ? 0 : 1;
2544
+
2545
+ return t1 - t2 || b.name.localeCompare(a.name) * reverse;
2546
+ });
2547
+ }
2548
+ return copy;
2549
+ }
2550
+
2551
+ /**
2552
+ * Get the index of the node at a client position, or `-1`.
2553
+ */
2554
+ export function hitTestNodes(
2555
+ nodes: HTMLElement[],
2556
+ event: MouseEvent
2557
+ ): number {
2558
+ return ArrayExt.findFirstIndex(
2559
+ nodes,
2560
+ node =>
2561
+ ElementExt.hitTest(node, event.clientX, event.clientY) ||
2562
+ event.target === node
2563
+ );
2564
+ }
2565
+
2566
+ /**
2567
+ * Format bytes to human readable string.
2568
+ */
2569
+ export function formatFileSize(
2570
+ bytes: number,
2571
+ decimalPoint: number,
2572
+ k: number
2573
+ ): string {
2574
+ // https://www.codexworld.com/how-to/convert-file-size-bytes-kb-mb-gb-javascript/
2575
+ if (bytes === 0) {
2576
+ return '0 B';
2577
+ }
2578
+ const dm = decimalPoint || 2;
2579
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
2580
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
2581
+ if (i >= 0 && i < sizes.length) {
2582
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
2583
+ } else {
2584
+ return String(bytes);
2585
+ }
2586
+ }
2587
+
2588
+ /**
2589
+ * Update an inline svg caret icon in a node.
2590
+ */
2591
+ export function updateCaret(
2592
+ container: HTMLElement,
2593
+ float: 'left' | 'right',
2594
+ state?: 'down' | 'up' | undefined
2595
+ ): void {
2596
+ if (state) {
2597
+ (state === 'down' ? caretDownIcon : caretUpIcon).element({
2598
+ container,
2599
+ tag: 'span',
2600
+ stylesheet: 'listingHeaderItem',
2601
+
2602
+ float
2603
+ });
2604
+ } else {
2605
+ LabIcon.remove(container);
2606
+ container.className = HEADER_ITEM_ICON_CLASS;
2607
+ }
2608
+ }
2609
+ }