@jupyterlab/filebrowser 4.0.0-alpha.8 → 4.0.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/model.ts ADDED
@@ -0,0 +1,832 @@
1
+ // Copyright (c) Jupyter Development Team.
2
+ // Distributed under the terms of the Modified BSD License.
3
+
4
+ import { Dialog, showDialog } from '@jupyterlab/apputils';
5
+ import { IChangedArgs, PageConfig, PathExt } from '@jupyterlab/coreutils';
6
+ import { IDocumentManager, shouldOverwrite } from '@jupyterlab/docmanager';
7
+ import { Contents, KernelSpec, Session } from '@jupyterlab/services';
8
+ import { IStateDB } from '@jupyterlab/statedb';
9
+ import {
10
+ ITranslator,
11
+ nullTranslator,
12
+ TranslationBundle
13
+ } from '@jupyterlab/translation';
14
+ import { IScore } from '@jupyterlab/ui-components';
15
+ import { ArrayExt, filter } from '@lumino/algorithm';
16
+ import { PromiseDelegate, ReadonlyJSONObject } from '@lumino/coreutils';
17
+ import { IDisposable } from '@lumino/disposable';
18
+ import { Poll } from '@lumino/polling';
19
+ import { ISignal, Signal } from '@lumino/signaling';
20
+
21
+ /**
22
+ * The default duration of the auto-refresh in ms
23
+ */
24
+ const DEFAULT_REFRESH_INTERVAL = 10000;
25
+
26
+ /**
27
+ * The maximum upload size (in bytes) for notebook version < 5.1.0
28
+ */
29
+ export const LARGE_FILE_SIZE = 15 * 1024 * 1024;
30
+
31
+ /**
32
+ * The size (in bytes) of the biggest chunk we should upload at once.
33
+ */
34
+ export const CHUNK_SIZE = 1024 * 1024;
35
+
36
+ /**
37
+ * An upload progress event for a file at `path`.
38
+ */
39
+ export interface IUploadModel {
40
+ path: string;
41
+ /**
42
+ * % uploaded [0, 1)
43
+ */
44
+ progress: number;
45
+ }
46
+
47
+ /**
48
+ * An implementation of a file browser model.
49
+ *
50
+ * #### Notes
51
+ * All paths parameters without a leading `'/'` are interpreted as relative to
52
+ * the current directory. Supports `'../'` syntax.
53
+ */
54
+ export class FileBrowserModel implements IDisposable {
55
+ /**
56
+ * Construct a new file browser model.
57
+ */
58
+ constructor(options: FileBrowserModel.IOptions) {
59
+ this.manager = options.manager;
60
+ this.translator = options.translator || nullTranslator;
61
+ this._trans = this.translator.load('jupyterlab');
62
+ this._driveName = options.driveName || '';
63
+ this._model = {
64
+ path: this.rootPath,
65
+ name: PathExt.basename(this.rootPath),
66
+ type: 'directory',
67
+ content: undefined,
68
+ writable: false,
69
+ created: 'unknown',
70
+ last_modified: 'unknown',
71
+ mimetype: 'text/plain',
72
+ format: 'text'
73
+ };
74
+ this._state = options.state || null;
75
+ const refreshInterval = options.refreshInterval || DEFAULT_REFRESH_INTERVAL;
76
+
77
+ const { services } = options.manager;
78
+ services.contents.fileChanged.connect(this.onFileChanged, this);
79
+ services.sessions.runningChanged.connect(this.onRunningChanged, this);
80
+
81
+ this._unloadEventListener = (e: Event) => {
82
+ if (this._uploads.length > 0) {
83
+ const confirmationMessage = this._trans.__('Files still uploading');
84
+
85
+ (e as any).returnValue = confirmationMessage;
86
+ return confirmationMessage;
87
+ }
88
+ };
89
+ window.addEventListener('beforeunload', this._unloadEventListener);
90
+ this._poll = new Poll({
91
+ auto: options.auto ?? true,
92
+ name: '@jupyterlab/filebrowser:Model',
93
+ factory: () => this.cd('.'),
94
+ frequency: {
95
+ interval: refreshInterval,
96
+ backoff: true,
97
+ max: 300 * 1000
98
+ },
99
+ standby: options.refreshStandby || 'when-hidden'
100
+ });
101
+ }
102
+
103
+ /**
104
+ * The document manager instance used by the file browser model.
105
+ */
106
+ readonly manager: IDocumentManager;
107
+
108
+ /**
109
+ * A signal emitted when the file browser model loses connection.
110
+ */
111
+ get connectionFailure(): ISignal<this, Error> {
112
+ return this._connectionFailure;
113
+ }
114
+
115
+ /**
116
+ * The drive name that gets prepended to the path.
117
+ */
118
+ get driveName(): string {
119
+ return this._driveName;
120
+ }
121
+
122
+ /**
123
+ * A promise that resolves when the model is first restored.
124
+ */
125
+ get restored(): Promise<void> {
126
+ return this._restored.promise;
127
+ }
128
+
129
+ /**
130
+ * Get the file path changed signal.
131
+ */
132
+ get fileChanged(): ISignal<this, Contents.IChangedArgs> {
133
+ return this._fileChanged;
134
+ }
135
+
136
+ /**
137
+ * Get the current path.
138
+ */
139
+ get path(): string {
140
+ return this._model ? this._model.path : '';
141
+ }
142
+
143
+ /**
144
+ * Get the root path
145
+ */
146
+ get rootPath(): string {
147
+ return this._driveName ? this._driveName + ':' : '';
148
+ }
149
+
150
+ /**
151
+ * A signal emitted when the path changes.
152
+ */
153
+ get pathChanged(): ISignal<this, IChangedArgs<string>> {
154
+ return this._pathChanged;
155
+ }
156
+
157
+ /**
158
+ * A signal emitted when the directory listing is refreshed.
159
+ */
160
+ get refreshed(): ISignal<this, void> {
161
+ return this._refreshed;
162
+ }
163
+
164
+ /**
165
+ * Get the kernel spec models.
166
+ */
167
+ get specs(): KernelSpec.ISpecModels | null {
168
+ return this.manager.services.kernelspecs.specs;
169
+ }
170
+
171
+ /**
172
+ * Get whether the model is disposed.
173
+ */
174
+ get isDisposed(): boolean {
175
+ return this._isDisposed;
176
+ }
177
+
178
+ /**
179
+ * A signal emitted when an upload progresses.
180
+ */
181
+ get uploadChanged(): ISignal<this, IChangedArgs<IUploadModel | null>> {
182
+ return this._uploadChanged;
183
+ }
184
+
185
+ /**
186
+ * Create an iterator over the status of all in progress uploads.
187
+ */
188
+ uploads(): IterableIterator<IUploadModel> {
189
+ return this._uploads[Symbol.iterator]();
190
+ }
191
+
192
+ /**
193
+ * Dispose of the resources held by the model.
194
+ */
195
+ dispose(): void {
196
+ if (this.isDisposed) {
197
+ return;
198
+ }
199
+ window.removeEventListener('beforeunload', this._unloadEventListener);
200
+ this._isDisposed = true;
201
+ this._poll.dispose();
202
+ this._sessions.length = 0;
203
+ this._items.length = 0;
204
+ Signal.clearData(this);
205
+ }
206
+
207
+ /**
208
+ * Create an iterator over the model's items.
209
+ *
210
+ * @returns A new iterator over the model's items.
211
+ */
212
+ items(): IterableIterator<Contents.IModel> {
213
+ return this._items[Symbol.iterator]();
214
+ }
215
+
216
+ /**
217
+ * Create an iterator over the active sessions in the directory.
218
+ *
219
+ * @returns A new iterator over the model's active sessions.
220
+ */
221
+ sessions(): IterableIterator<Session.IModel> {
222
+ return this._sessions[Symbol.iterator]();
223
+ }
224
+
225
+ /**
226
+ * Force a refresh of the directory contents.
227
+ */
228
+ async refresh(): Promise<void> {
229
+ await this._poll.refresh();
230
+ await this._poll.tick;
231
+ this._refreshed.emit(void 0);
232
+ }
233
+
234
+ /**
235
+ * Change directory.
236
+ *
237
+ * @param path - The path to the file or directory.
238
+ *
239
+ * @returns A promise with the contents of the directory.
240
+ */
241
+ async cd(newValue = '.'): Promise<void> {
242
+ if (newValue !== '.') {
243
+ newValue = this.manager.services.contents.resolvePath(
244
+ this._model.path,
245
+ newValue
246
+ );
247
+ } else {
248
+ newValue = this._pendingPath || this._model.path;
249
+ }
250
+ if (this._pending) {
251
+ // Collapse requests to the same directory.
252
+ if (newValue === this._pendingPath) {
253
+ return this._pending;
254
+ }
255
+ // Otherwise wait for the pending request to complete before continuing.
256
+ await this._pending;
257
+ }
258
+ const oldValue = this.path;
259
+ const options: Contents.IFetchOptions = { content: true };
260
+ this._pendingPath = newValue;
261
+ if (oldValue !== newValue) {
262
+ this._sessions.length = 0;
263
+ }
264
+ const services = this.manager.services;
265
+ this._pending = services.contents
266
+ .get(newValue, options)
267
+ .then(contents => {
268
+ if (this.isDisposed) {
269
+ return;
270
+ }
271
+ this.handleContents(contents);
272
+ this._pendingPath = null;
273
+ this._pending = null;
274
+ if (oldValue !== newValue) {
275
+ // If there is a state database and a unique key, save the new path.
276
+ // We don't need to wait on the save to continue.
277
+ if (this._state && this._key) {
278
+ void this._state.save(this._key, { path: newValue });
279
+ }
280
+
281
+ this._pathChanged.emit({
282
+ name: 'path',
283
+ oldValue,
284
+ newValue
285
+ });
286
+ }
287
+ this.onRunningChanged(services.sessions, services.sessions.running());
288
+ this._refreshed.emit(void 0);
289
+ })
290
+ .catch(error => {
291
+ this._pendingPath = null;
292
+ this._pending = null;
293
+ if (
294
+ error.response &&
295
+ error.response.status === 404 &&
296
+ newValue !== '/'
297
+ ) {
298
+ error.message = this._trans.__(
299
+ 'Directory not found: "%1"',
300
+ this._model.path
301
+ );
302
+ console.error(error);
303
+ this._connectionFailure.emit(error);
304
+ return this.cd('/');
305
+ } else {
306
+ this._connectionFailure.emit(error);
307
+ }
308
+ });
309
+ return this._pending;
310
+ }
311
+
312
+ /**
313
+ * Download a file.
314
+ *
315
+ * @param path - The path of the file to be downloaded.
316
+ *
317
+ * @returns A promise which resolves when the file has begun
318
+ * downloading.
319
+ */
320
+ async download(path: string): Promise<void> {
321
+ const url = await this.manager.services.contents.getDownloadUrl(path);
322
+ const element = document.createElement('a');
323
+ element.href = url;
324
+ element.download = '';
325
+ document.body.appendChild(element);
326
+ element.click();
327
+ document.body.removeChild(element);
328
+ return void 0;
329
+ }
330
+
331
+ /**
332
+ * Restore the state of the file browser.
333
+ *
334
+ * @param id - The unique ID that is used to construct a state database key.
335
+ *
336
+ * @param populate - If `false`, the restoration ID will be set but the file
337
+ * browser state will not be fetched from the state database.
338
+ *
339
+ * @returns A promise when restoration is complete.
340
+ *
341
+ * #### Notes
342
+ * This function will only restore the model *once*. If it is called multiple
343
+ * times, all subsequent invocations are no-ops.
344
+ */
345
+ async restore(id: string, populate = true): Promise<void> {
346
+ const { manager } = this;
347
+ const key = `file-browser-${id}:cwd`;
348
+ const state = this._state;
349
+ const restored = !!this._key;
350
+
351
+ if (restored) {
352
+ return;
353
+ }
354
+
355
+ // Set the file browser key for state database fetch/save.
356
+ this._key = key;
357
+
358
+ if (!populate || !state) {
359
+ this._restored.resolve(undefined);
360
+ return;
361
+ }
362
+
363
+ await manager.services.ready;
364
+
365
+ try {
366
+ const value = await state.fetch(key);
367
+
368
+ if (!value) {
369
+ this._restored.resolve(undefined);
370
+ return;
371
+ }
372
+
373
+ const path = (value as ReadonlyJSONObject)['path'] as string;
374
+ // need to return to root path if preferred dir is set
375
+ if (path) {
376
+ await this.cd('/');
377
+ }
378
+ const localPath = manager.services.contents.localPath(path);
379
+
380
+ await manager.services.contents.get(path);
381
+ await this.cd(localPath);
382
+ } catch (error) {
383
+ await state.remove(key);
384
+ }
385
+
386
+ this._restored.resolve(undefined);
387
+ }
388
+
389
+ /**
390
+ * Upload a `File` object.
391
+ *
392
+ * @param file - The `File` object to upload.
393
+ *
394
+ * @returns A promise containing the new file contents model.
395
+ *
396
+ * #### Notes
397
+ * On Notebook version < 5.1.0, this will fail to upload files that are too
398
+ * big to be sent in one request to the server. On newer versions, or on
399
+ * Jupyter Server, it will ask for confirmation then upload the file in 1 MB
400
+ * chunks.
401
+ */
402
+ async upload(file: File): Promise<Contents.IModel> {
403
+ // We do not support Jupyter Notebook version less than 4, and Jupyter
404
+ // Server advertises itself as version 1 and supports chunked
405
+ // uploading. We assume any version less than 4.0.0 to be Jupyter Server
406
+ // instead of Jupyter Notebook.
407
+ const serverVersion = PageConfig.getNotebookVersion();
408
+ const supportsChunked =
409
+ serverVersion < [4, 0, 0] /* Jupyter Server */ ||
410
+ serverVersion >= [5, 1, 0]; /* Jupyter Notebook >= 5.1.0 */
411
+ const largeFile = file.size > LARGE_FILE_SIZE;
412
+
413
+ if (largeFile && !supportsChunked) {
414
+ const msg = this._trans.__(
415
+ 'Cannot upload file (>%1 MB). %2',
416
+ LARGE_FILE_SIZE / (1024 * 1024),
417
+ file.name
418
+ );
419
+ console.warn(msg);
420
+ throw msg;
421
+ }
422
+
423
+ const err = 'File not uploaded';
424
+ if (largeFile && !(await this._shouldUploadLarge(file))) {
425
+ throw 'Cancelled large file upload';
426
+ }
427
+ await this._uploadCheckDisposed();
428
+ await this.refresh();
429
+ await this._uploadCheckDisposed();
430
+ if (
431
+ this._items.find(i => i.name === file.name) &&
432
+ !(await shouldOverwrite(file.name))
433
+ ) {
434
+ throw err;
435
+ }
436
+ await this._uploadCheckDisposed();
437
+ const chunkedUpload = supportsChunked && file.size > CHUNK_SIZE;
438
+ return await this._upload(file, chunkedUpload);
439
+ }
440
+
441
+ private async _shouldUploadLarge(file: File): Promise<boolean> {
442
+ const { button } = await showDialog({
443
+ title: this._trans.__('Large file size warning'),
444
+ body: this._trans.__(
445
+ 'The file size is %1 MB. Do you still want to upload it?',
446
+ Math.round(file.size / (1024 * 1024))
447
+ ),
448
+ buttons: [
449
+ Dialog.cancelButton({ label: this._trans.__('Cancel') }),
450
+ Dialog.warnButton({ label: this._trans.__('Upload') })
451
+ ]
452
+ });
453
+ return button.accept;
454
+ }
455
+
456
+ /**
457
+ * Perform the actual upload.
458
+ */
459
+ private async _upload(
460
+ file: File,
461
+ chunked: boolean
462
+ ): Promise<Contents.IModel> {
463
+ // Gather the file model parameters.
464
+ let path = this._model.path;
465
+ path = path ? path + '/' + file.name : file.name;
466
+ const name = file.name;
467
+ const type: Contents.ContentType = 'file';
468
+ const format: Contents.FileFormat = 'base64';
469
+
470
+ const uploadInner = async (
471
+ blob: Blob,
472
+ chunk?: number
473
+ ): Promise<Contents.IModel> => {
474
+ await this._uploadCheckDisposed();
475
+ const reader = new FileReader();
476
+ reader.readAsDataURL(blob);
477
+ await new Promise((resolve, reject) => {
478
+ reader.onload = resolve;
479
+ reader.onerror = event =>
480
+ reject(`Failed to upload "${file.name}":` + event);
481
+ });
482
+ await this._uploadCheckDisposed();
483
+
484
+ // remove header https://stackoverflow.com/a/24289420/907060
485
+ const content = (reader.result as string).split(',')[1];
486
+
487
+ const model: Partial<Contents.IModel> = {
488
+ type,
489
+ format,
490
+ name,
491
+ chunk,
492
+ content
493
+ };
494
+ return await this.manager.services.contents.save(path, model);
495
+ };
496
+
497
+ if (!chunked) {
498
+ try {
499
+ return await uploadInner(file);
500
+ } catch (err) {
501
+ ArrayExt.removeFirstWhere(this._uploads, uploadIndex => {
502
+ return file.name === uploadIndex.path;
503
+ });
504
+ throw err;
505
+ }
506
+ }
507
+
508
+ let finalModel: Contents.IModel | undefined;
509
+
510
+ let upload = { path, progress: 0 };
511
+ this._uploadChanged.emit({
512
+ name: 'start',
513
+ newValue: upload,
514
+ oldValue: null
515
+ });
516
+
517
+ for (let start = 0; !finalModel; start += CHUNK_SIZE) {
518
+ const end = start + CHUNK_SIZE;
519
+ const lastChunk = end >= file.size;
520
+ const chunk = lastChunk ? -1 : end / CHUNK_SIZE;
521
+
522
+ const newUpload = { path, progress: start / file.size };
523
+ this._uploads.splice(this._uploads.indexOf(upload));
524
+ this._uploads.push(newUpload);
525
+ this._uploadChanged.emit({
526
+ name: 'update',
527
+ newValue: newUpload,
528
+ oldValue: upload
529
+ });
530
+ upload = newUpload;
531
+
532
+ let currentModel: Contents.IModel;
533
+ try {
534
+ currentModel = await uploadInner(file.slice(start, end), chunk);
535
+ } catch (err) {
536
+ ArrayExt.removeFirstWhere(this._uploads, uploadIndex => {
537
+ return file.name === uploadIndex.path;
538
+ });
539
+
540
+ this._uploadChanged.emit({
541
+ name: 'failure',
542
+ newValue: upload,
543
+ oldValue: null
544
+ });
545
+
546
+ throw err;
547
+ }
548
+
549
+ if (lastChunk) {
550
+ finalModel = currentModel;
551
+ }
552
+ }
553
+
554
+ this._uploads.splice(this._uploads.indexOf(upload));
555
+ this._uploadChanged.emit({
556
+ name: 'finish',
557
+ newValue: null,
558
+ oldValue: upload
559
+ });
560
+
561
+ return finalModel;
562
+ }
563
+
564
+ private _uploadCheckDisposed(): Promise<void> {
565
+ if (this.isDisposed) {
566
+ return Promise.reject('Filemanager disposed. File upload canceled');
567
+ }
568
+ return Promise.resolve();
569
+ }
570
+
571
+ /**
572
+ * Handle an updated contents model.
573
+ */
574
+ protected handleContents(contents: Contents.IModel): void {
575
+ // Update our internal data.
576
+ this._model = {
577
+ name: contents.name,
578
+ path: contents.path,
579
+ type: contents.type,
580
+ content: undefined,
581
+ writable: contents.writable,
582
+ created: contents.created,
583
+ last_modified: contents.last_modified,
584
+ size: contents.size,
585
+ mimetype: contents.mimetype,
586
+ format: contents.format
587
+ };
588
+ this._items = contents.content;
589
+ this._paths.clear();
590
+ contents.content.forEach((model: Contents.IModel) => {
591
+ this._paths.add(model.path);
592
+ });
593
+ }
594
+
595
+ /**
596
+ * Handle a change to the running sessions.
597
+ */
598
+ protected onRunningChanged(
599
+ sender: Session.IManager,
600
+ models: Iterable<Session.IModel>
601
+ ): void {
602
+ this._populateSessions(models);
603
+ this._refreshed.emit(void 0);
604
+ }
605
+
606
+ /**
607
+ * Handle a change on the contents manager.
608
+ */
609
+ protected onFileChanged(
610
+ sender: Contents.IManager,
611
+ change: Contents.IChangedArgs
612
+ ): void {
613
+ const path = this._model.path;
614
+ const { sessions } = this.manager.services;
615
+ const { oldValue, newValue } = change;
616
+ const value =
617
+ oldValue && oldValue.path && PathExt.dirname(oldValue.path) === path
618
+ ? oldValue
619
+ : newValue && newValue.path && PathExt.dirname(newValue.path) === path
620
+ ? newValue
621
+ : undefined;
622
+
623
+ // If either the old value or the new value is in the current path, update.
624
+ if (value) {
625
+ void this._poll.refresh();
626
+ this._populateSessions(sessions.running());
627
+ this._fileChanged.emit(change);
628
+ return;
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Populate the model's sessions collection.
634
+ */
635
+ private _populateSessions(models: Iterable<Session.IModel>): void {
636
+ this._sessions.length = 0;
637
+ for (const model of models) {
638
+ if (this._paths.has(model.path)) {
639
+ this._sessions.push(model);
640
+ }
641
+ }
642
+ }
643
+
644
+ protected translator: ITranslator;
645
+ private _trans: TranslationBundle;
646
+ private _connectionFailure = new Signal<this, Error>(this);
647
+ private _fileChanged = new Signal<this, Contents.IChangedArgs>(this);
648
+ private _items: Contents.IModel[] = [];
649
+ private _key: string = '';
650
+ private _model: Contents.IModel;
651
+ private _pathChanged = new Signal<this, IChangedArgs<string>>(this);
652
+ private _paths = new Set<string>();
653
+ private _pending: Promise<void> | null = null;
654
+ private _pendingPath: string | null = null;
655
+ private _refreshed = new Signal<this, void>(this);
656
+ private _sessions: Session.IModel[] = [];
657
+ private _state: IStateDB | null = null;
658
+ private _driveName: string;
659
+ private _isDisposed = false;
660
+ private _restored = new PromiseDelegate<void>();
661
+ private _uploads: IUploadModel[] = [];
662
+ private _uploadChanged = new Signal<this, IChangedArgs<IUploadModel | null>>(
663
+ this
664
+ );
665
+ private _unloadEventListener: (e: Event) => string | undefined;
666
+ private _poll: Poll;
667
+ }
668
+
669
+ /**
670
+ * The namespace for the `FileBrowserModel` class statics.
671
+ */
672
+ export namespace FileBrowserModel {
673
+ /**
674
+ * An options object for initializing a file browser.
675
+ */
676
+ export interface IOptions {
677
+ /**
678
+ * Whether a file browser automatically loads its initial path.
679
+ * The default is `true`.
680
+ */
681
+ auto?: boolean;
682
+
683
+ /**
684
+ * An optional `Contents.IDrive` name for the model.
685
+ * If given, the model will prepend `driveName:` to
686
+ * all paths used in file operations.
687
+ */
688
+ driveName?: string;
689
+
690
+ /**
691
+ * A document manager instance.
692
+ */
693
+ manager: IDocumentManager;
694
+
695
+ /**
696
+ * The time interval for browser refreshing, in ms.
697
+ */
698
+ refreshInterval?: number;
699
+
700
+ /**
701
+ * When the model stops polling the API. Defaults to `when-hidden`.
702
+ */
703
+ refreshStandby?: Poll.Standby | (() => boolean | Poll.Standby);
704
+
705
+ /**
706
+ * An optional state database. If provided, the model will restore which
707
+ * folder was last opened when it is restored.
708
+ */
709
+ state?: IStateDB;
710
+
711
+ /**
712
+ * The application language translator.
713
+ */
714
+ translator?: ITranslator;
715
+ }
716
+ }
717
+
718
+ /**
719
+ * File browser model where hidden files inclusion can be toggled on/off.
720
+ */
721
+ export class TogglableHiddenFileBrowserModel extends FileBrowserModel {
722
+ constructor(options: TogglableHiddenFileBrowserModel.IOptions) {
723
+ super(options);
724
+ this._includeHiddenFiles = options.includeHiddenFiles || false;
725
+ }
726
+
727
+ /**
728
+ * Create an iterator over the model's items filtering hidden files out if necessary.
729
+ *
730
+ * @returns A new iterator over the model's items.
731
+ */
732
+ items(): IterableIterator<Contents.IModel> {
733
+ return this._includeHiddenFiles
734
+ ? super.items()
735
+ : filter(super.items(), value => !value.name.startsWith('.'));
736
+ }
737
+
738
+ /**
739
+ * Set the inclusion of hidden files. Triggers a model refresh.
740
+ */
741
+ showHiddenFiles(value: boolean): void {
742
+ this._includeHiddenFiles = value;
743
+ void this.refresh();
744
+ }
745
+
746
+ private _includeHiddenFiles: boolean;
747
+ }
748
+
749
+ /**
750
+ * Namespace for the togglable hidden file browser model
751
+ */
752
+ export namespace TogglableHiddenFileBrowserModel {
753
+ /**
754
+ * Constructor options
755
+ */
756
+ export interface IOptions extends FileBrowserModel.IOptions {
757
+ /**
758
+ * Whether hidden files should be included in the items.
759
+ */
760
+ includeHiddenFiles?: boolean;
761
+ }
762
+ }
763
+
764
+ /**
765
+ * File browser model with optional filter on element.
766
+ */
767
+ export class FilterFileBrowserModel extends TogglableHiddenFileBrowserModel {
768
+ constructor(options: FilterFileBrowserModel.IOptions) {
769
+ super(options);
770
+ this._filter =
771
+ options.filter ??
772
+ (model => {
773
+ return {};
774
+ });
775
+ this._filterDirectories = options.filterDirectories ?? true;
776
+ }
777
+
778
+ /**
779
+ * Whether to filter directories.
780
+ */
781
+ get filterDirectories(): boolean {
782
+ return this._filterDirectories;
783
+ }
784
+ set filterDirectories(value: boolean) {
785
+ this._filterDirectories = value;
786
+ }
787
+
788
+ /**
789
+ * Create an iterator over the filtered model's items.
790
+ *
791
+ * @returns A new iterator over the model's items.
792
+ */
793
+ items(): IterableIterator<Contents.IModel> {
794
+ return filter(super.items(), value => {
795
+ if (!this._filterDirectories && value.type === 'directory') {
796
+ return true;
797
+ } else {
798
+ const filtered = this._filter(value);
799
+ value.indices = filtered?.indices;
800
+ return !!filtered;
801
+ }
802
+ });
803
+ }
804
+
805
+ setFilter(filter: (value: Contents.IModel) => Partial<IScore> | null): void {
806
+ this._filter = filter;
807
+ void this.refresh();
808
+ }
809
+
810
+ private _filter: (value: Contents.IModel) => Partial<IScore> | null;
811
+ private _filterDirectories: boolean;
812
+ }
813
+
814
+ /**
815
+ * Namespace for the filtered file browser model
816
+ */
817
+ export namespace FilterFileBrowserModel {
818
+ /**
819
+ * Constructor options
820
+ */
821
+ export interface IOptions extends TogglableHiddenFileBrowserModel.IOptions {
822
+ /**
823
+ * Filter function on file browser item model
824
+ */
825
+ filter?: (value: Contents.IModel) => Partial<IScore> | null;
826
+
827
+ /**
828
+ * Filter directories
829
+ */
830
+ filterDirectories?: boolean;
831
+ }
832
+ }