@jupyterlab/filebrowser 4.6.0-alpha.4 → 4.6.0-alpha.5
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/lib/browser.d.ts +14 -0
- package/lib/browser.js +48 -1
- package/lib/browser.js.map +1 -1
- package/lib/crumbs.d.ts +76 -0
- package/lib/crumbs.js +362 -81
- package/lib/crumbs.js.map +1 -1
- package/lib/listing.d.ts +84 -11
- package/lib/listing.js +276 -99
- package/lib/listing.js.map +1 -1
- package/lib/model.d.ts +25 -0
- package/lib/model.js +48 -2
- package/lib/model.js.map +1 -1
- package/lib/opendialog.d.ts +7 -0
- package/lib/opendialog.js +5 -4
- package/lib/opendialog.js.map +1 -1
- package/lib/pathnavigator.d.ts +108 -0
- package/lib/pathnavigator.js +389 -0
- package/lib/pathnavigator.js.map +1 -0
- package/package.json +11 -11
- package/src/browser.ts +50 -1
- package/src/crumbs.ts +432 -87
- package/src/listing.ts +418 -119
- package/src/model.ts +63 -2
- package/src/opendialog.ts +13 -0
- package/src/pathnavigator.ts +456 -0
- package/style/base.css +64 -35
- package/style/pathnavigator.css +72 -0
package/src/model.ts
CHANGED
|
@@ -61,6 +61,7 @@ export class FileBrowserModel implements IDisposable {
|
|
|
61
61
|
this.translator = options.translator || nullTranslator;
|
|
62
62
|
this._trans = this.translator.load('jupyterlab');
|
|
63
63
|
this._driveName = options.driveName || '';
|
|
64
|
+
this._root = options.root || '';
|
|
64
65
|
this._allowFileUploads = options.allowFileUploads ?? true;
|
|
65
66
|
this._model = {
|
|
66
67
|
path: this.rootPath,
|
|
@@ -149,6 +150,15 @@ export class FileBrowserModel implements IDisposable {
|
|
|
149
150
|
return this._driveName ? this._driveName + ':' : '';
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Get the navigation root path.
|
|
155
|
+
*
|
|
156
|
+
* When set, navigation is restricted to this path and its subdirectories.
|
|
157
|
+
*/
|
|
158
|
+
get root(): string {
|
|
159
|
+
return this._root;
|
|
160
|
+
}
|
|
161
|
+
|
|
152
162
|
/**
|
|
153
163
|
* A signal emitted when the path changes.
|
|
154
164
|
*/
|
|
@@ -241,11 +251,21 @@ export class FileBrowserModel implements IDisposable {
|
|
|
241
251
|
* @returns A promise with the contents of the directory.
|
|
242
252
|
*/
|
|
243
253
|
async cd(path = '.'): Promise<void> {
|
|
254
|
+
const isRefresh = path === '.';
|
|
244
255
|
if (path !== '.') {
|
|
245
256
|
path = this.manager.services.contents.resolvePath(this._model.path, path);
|
|
246
257
|
} else {
|
|
247
258
|
path = this._pendingPath || this._model.path;
|
|
248
259
|
}
|
|
260
|
+
// Check if navigation is restricted and the path is outside the root
|
|
261
|
+
if (this._root && !this._isPathWithinRoot(path)) {
|
|
262
|
+
if (isRefresh) {
|
|
263
|
+
// During refresh, if current path is outside root, navigate to root
|
|
264
|
+
path = this._root;
|
|
265
|
+
} else {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
249
269
|
if (this._pending) {
|
|
250
270
|
// Collapse requests to the same directory.
|
|
251
271
|
if (path === this._pendingPath) {
|
|
@@ -289,14 +309,19 @@ export class FileBrowserModel implements IDisposable {
|
|
|
289
309
|
.catch(error => {
|
|
290
310
|
this._pendingPath = null;
|
|
291
311
|
this._pending = null;
|
|
292
|
-
|
|
312
|
+
const fallbackPath = this._root || '/';
|
|
313
|
+
if (
|
|
314
|
+
error.response &&
|
|
315
|
+
error.response.status === 404 &&
|
|
316
|
+
path !== fallbackPath
|
|
317
|
+
) {
|
|
293
318
|
error.message = this._trans.__(
|
|
294
319
|
'Directory not found: "%1"',
|
|
295
320
|
this._model.path
|
|
296
321
|
);
|
|
297
322
|
console.error(error);
|
|
298
323
|
this._connectionFailure.emit(error);
|
|
299
|
-
return this.cd(
|
|
324
|
+
return this.cd(fallbackPath);
|
|
300
325
|
} else {
|
|
301
326
|
this._connectionFailure.emit(error);
|
|
302
327
|
}
|
|
@@ -648,6 +673,26 @@ export class FileBrowserModel implements IDisposable {
|
|
|
648
673
|
}
|
|
649
674
|
}
|
|
650
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Check if a path is within the navigation root.
|
|
678
|
+
*
|
|
679
|
+
* @param path - The path to check.
|
|
680
|
+
* @returns Whether the path is within the root boundary.
|
|
681
|
+
*/
|
|
682
|
+
private _isPathWithinRoot(path: string): boolean {
|
|
683
|
+
if (!this._root) {
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
// Normalize paths for comparison (remove trailing slashes)
|
|
687
|
+
const normalizedPath = PathExt.removeSlash(path);
|
|
688
|
+
const normalizedRoot = PathExt.removeSlash(this._root);
|
|
689
|
+
// Path is valid if it equals the root or is a subdirectory of root
|
|
690
|
+
return (
|
|
691
|
+
normalizedPath === normalizedRoot ||
|
|
692
|
+
normalizedPath.startsWith(normalizedRoot + '/')
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
651
696
|
/**
|
|
652
697
|
* Populate the model's sessions collection.
|
|
653
698
|
*/
|
|
@@ -675,6 +720,7 @@ export class FileBrowserModel implements IDisposable {
|
|
|
675
720
|
private _sessions: Session.IModel[] = [];
|
|
676
721
|
private _state: IStateDB | null = null;
|
|
677
722
|
private _driveName: string;
|
|
723
|
+
private _root: string;
|
|
678
724
|
private _allowFileUploads: boolean;
|
|
679
725
|
private _isDisposed = false;
|
|
680
726
|
private _restored = new PromiseDelegate<void>();
|
|
@@ -712,6 +758,14 @@ export namespace FileBrowserModel {
|
|
|
712
758
|
*/
|
|
713
759
|
manager: IDocumentManager;
|
|
714
760
|
|
|
761
|
+
/**
|
|
762
|
+
* The root path for navigation restriction.
|
|
763
|
+
*
|
|
764
|
+
* When set, navigation will be restricted to this path and its
|
|
765
|
+
* subdirectories. Users will not be able to navigate above this path.
|
|
766
|
+
*/
|
|
767
|
+
root?: string;
|
|
768
|
+
|
|
715
769
|
/**
|
|
716
770
|
* The time interval for browser refreshing, in ms.
|
|
717
771
|
*/
|
|
@@ -760,6 +814,13 @@ export class TogglableHiddenFileBrowserModel extends FileBrowserModel {
|
|
|
760
814
|
: filter(super.items(), value => !value.name.startsWith('.'));
|
|
761
815
|
}
|
|
762
816
|
|
|
817
|
+
/**
|
|
818
|
+
* Whether hidden files are currently included.
|
|
819
|
+
*/
|
|
820
|
+
get includeHiddenFiles(): boolean {
|
|
821
|
+
return this._includeHiddenFiles;
|
|
822
|
+
}
|
|
823
|
+
|
|
763
824
|
/**
|
|
764
825
|
* Set the inclusion of hidden files. Triggers a model refresh.
|
|
765
826
|
*/
|
package/src/opendialog.ts
CHANGED
|
@@ -56,6 +56,14 @@ export namespace FileDialog {
|
|
|
56
56
|
*/
|
|
57
57
|
defaultPath?: string;
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The root path for navigation.
|
|
61
|
+
*
|
|
62
|
+
* When set, navigation will be restricted to this path and its
|
|
63
|
+
* subdirectories. Users will not be able to navigate above this path.
|
|
64
|
+
*/
|
|
65
|
+
root?: string;
|
|
66
|
+
|
|
59
67
|
/**
|
|
60
68
|
* Text to display above the file browser.
|
|
61
69
|
*/
|
|
@@ -132,6 +140,7 @@ class OpenDialog extends Dialog<Contents.IModel[]> {
|
|
|
132
140
|
options.filter,
|
|
133
141
|
translator,
|
|
134
142
|
options.defaultPath,
|
|
143
|
+
options.root,
|
|
135
144
|
options.label,
|
|
136
145
|
true,
|
|
137
146
|
handleOpenFile
|
|
@@ -165,6 +174,7 @@ class OpenDialogBody
|
|
|
165
174
|
filter?: (value: Contents.IModel) => Partial<IScore> | null,
|
|
166
175
|
translator?: ITranslator,
|
|
167
176
|
defaultPath?: string,
|
|
177
|
+
root?: string,
|
|
168
178
|
label?: string,
|
|
169
179
|
filterDirectories?: boolean,
|
|
170
180
|
handleOpenFile?: (path: string) => void
|
|
@@ -181,6 +191,7 @@ class OpenDialogBody
|
|
|
181
191
|
{},
|
|
182
192
|
translator,
|
|
183
193
|
defaultPath,
|
|
194
|
+
root,
|
|
184
195
|
filterDirectories,
|
|
185
196
|
handleOpenFile
|
|
186
197
|
)
|
|
@@ -318,6 +329,7 @@ namespace Private {
|
|
|
318
329
|
options: IFileBrowserFactory.IOptions = {},
|
|
319
330
|
translator?: ITranslator,
|
|
320
331
|
defaultPath?: string,
|
|
332
|
+
root?: string,
|
|
321
333
|
filterDirectories?: boolean,
|
|
322
334
|
handleOpenFile?: (path: string) => void
|
|
323
335
|
): Promise<FileBrowser> => {
|
|
@@ -328,6 +340,7 @@ namespace Private {
|
|
|
328
340
|
translator,
|
|
329
341
|
driveName: options.driveName,
|
|
330
342
|
refreshInterval: options.refreshInterval,
|
|
343
|
+
root,
|
|
331
344
|
filterDirectories
|
|
332
345
|
});
|
|
333
346
|
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
// Copyright (c) Jupyter Development Team.
|
|
2
|
+
// Distributed under the terms of the Modified BSD License.
|
|
3
|
+
|
|
4
|
+
import { showErrorMessage } from '@jupyterlab/apputils';
|
|
5
|
+
import type { ITranslator, TranslationBundle } from '@jupyterlab/translation';
|
|
6
|
+
import { nullTranslator } from '@jupyterlab/translation';
|
|
7
|
+
import type { Message } from '@lumino/messaging';
|
|
8
|
+
import type { ISignal } from '@lumino/signaling';
|
|
9
|
+
import { Signal } from '@lumino/signaling';
|
|
10
|
+
import { Widget } from '@lumino/widgets';
|
|
11
|
+
import type {
|
|
12
|
+
FileBrowserModel,
|
|
13
|
+
TogglableHiddenFileBrowserModel
|
|
14
|
+
} from './model';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* We cache per directory; in case the filesystem changes below us, we refresh
|
|
18
|
+
* every 5000 ms just in case; Note that on model update it should refresh as
|
|
19
|
+
* well; so this is extra precautions.
|
|
20
|
+
*/
|
|
21
|
+
const SUGGESTION_CACHE_TTL_MS = 5000;
|
|
22
|
+
|
|
23
|
+
const PATHNAVIGATOR_CLASS = 'jp-PathNavigator';
|
|
24
|
+
const PATHNAVIGATOR_SUGGESTIONS_CLASS = 'jp-PathNavigator-suggestions';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A widget that renders a path text input with directory autocomplete.
|
|
28
|
+
* It owns only the input field and the suggestions dropdown.
|
|
29
|
+
* The trigger button and edit-mode state are managed by the parent widget.
|
|
30
|
+
*/
|
|
31
|
+
export class PathNavigator extends Widget {
|
|
32
|
+
constructor(options: PathNavigator.IOptions) {
|
|
33
|
+
super({ node: document.createElement('span') });
|
|
34
|
+
this.addClass(PATHNAVIGATOR_CLASS);
|
|
35
|
+
|
|
36
|
+
this._model = options.model;
|
|
37
|
+
this._trans = (options.translator ?? nullTranslator).load('jupyterlab');
|
|
38
|
+
|
|
39
|
+
this._inputNode = document.createElement('input');
|
|
40
|
+
this._inputNode.type = 'text';
|
|
41
|
+
this._inputNode.placeholder = this._trans.__('Type a path…');
|
|
42
|
+
|
|
43
|
+
this._suggestionsNode = document.createElement('ul');
|
|
44
|
+
this._suggestionsNode.className = PATHNAVIGATOR_SUGGESTIONS_CLASS;
|
|
45
|
+
this._suggestionsNode.style.display = 'none';
|
|
46
|
+
|
|
47
|
+
this.node.appendChild(this._inputNode);
|
|
48
|
+
this.node.appendChild(this._suggestionsNode);
|
|
49
|
+
|
|
50
|
+
this._model.refreshed.connect(this._onModelRefreshed, this);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A signal emitted when the navigator closes (Escape, blur, or after
|
|
55
|
+
* navigation is committed). The parent widget should use this to exit
|
|
56
|
+
* edit mode.
|
|
57
|
+
*/
|
|
58
|
+
get closed(): ISignal<this, void> {
|
|
59
|
+
return this._closed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Dispose of the resources held by the widget.
|
|
64
|
+
*/
|
|
65
|
+
dispose(): void {
|
|
66
|
+
if (this.isDisposed) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this._model.refreshed.disconnect(this._onModelRefreshed, this);
|
|
70
|
+
Signal.clearData(this);
|
|
71
|
+
super.dispose();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Open the path input: prefill with the model's current path, focus,
|
|
76
|
+
* and load suggestions.
|
|
77
|
+
*/
|
|
78
|
+
open(): void {
|
|
79
|
+
this._isOpen = true;
|
|
80
|
+
const contents = this._model.manager.services.contents;
|
|
81
|
+
const currentPath = contents.localPath(this._model.path);
|
|
82
|
+
const prefill = currentPath ? currentPath + '/' : '';
|
|
83
|
+
this._inputNode.value = prefill;
|
|
84
|
+
// Defer focus so that callers (e.g. command palette) can finish their
|
|
85
|
+
// own focus cleanup before we grab focus. Without this, the palette's
|
|
86
|
+
// closing logic can steal focus back, triggering the blur→close handler
|
|
87
|
+
// and immediately exiting edit mode.
|
|
88
|
+
requestAnimationFrame(() => {
|
|
89
|
+
if (!this._isOpen || this.isDisposed) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this._inputNode.focus();
|
|
93
|
+
this._inputNode.setSelectionRange(prefill.length, prefill.length);
|
|
94
|
+
});
|
|
95
|
+
void this._updateSuggestions(prefill);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A message handler invoked on an `'after-attach'` message.
|
|
100
|
+
*/
|
|
101
|
+
protected onAfterAttach(msg: Message): void {
|
|
102
|
+
super.onAfterAttach(msg);
|
|
103
|
+
this._inputNode.addEventListener('input', this);
|
|
104
|
+
this._inputNode.addEventListener('keydown', this);
|
|
105
|
+
this._inputNode.addEventListener('blur', this);
|
|
106
|
+
// Use mousedown (not click) so we can preventDefault() before blur fires.
|
|
107
|
+
this._suggestionsNode.addEventListener('mousedown', this);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A message handler invoked on a `'before-detach'` message.
|
|
112
|
+
*/
|
|
113
|
+
protected onBeforeDetach(msg: Message): void {
|
|
114
|
+
this._inputNode.removeEventListener('input', this);
|
|
115
|
+
this._inputNode.removeEventListener('keydown', this);
|
|
116
|
+
this._inputNode.removeEventListener('blur', this);
|
|
117
|
+
this._suggestionsNode.removeEventListener('mousedown', this);
|
|
118
|
+
super.onBeforeDetach(msg);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
handleEvent(event: Event): void {
|
|
122
|
+
switch (event.type) {
|
|
123
|
+
case 'input':
|
|
124
|
+
void this._updateSuggestions(this._inputNode.value);
|
|
125
|
+
break;
|
|
126
|
+
case 'keydown':
|
|
127
|
+
this._evtKeydown(event as KeyboardEvent);
|
|
128
|
+
break;
|
|
129
|
+
case 'blur':
|
|
130
|
+
this._close();
|
|
131
|
+
break;
|
|
132
|
+
case 'mousedown':
|
|
133
|
+
this._evtSuggestionMousedown(event as MouseEvent);
|
|
134
|
+
break;
|
|
135
|
+
default:
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Close the input and notify the parent via the `closed` signal.
|
|
142
|
+
*/
|
|
143
|
+
private _close(): void {
|
|
144
|
+
if (!this._isOpen || this.isDisposed) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this._isOpen = false;
|
|
148
|
+
this._submittedLocalPath = null;
|
|
149
|
+
this._suggestionsNode.style.display = 'none';
|
|
150
|
+
this._closed.emit();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Navigate to the given path, then close the input.
|
|
155
|
+
*/
|
|
156
|
+
private _commitNavigation(path: string): void {
|
|
157
|
+
// Strip trailing slash (except bare root), then ensure a leading slash so
|
|
158
|
+
// model.cd() → resolvePath() treats this as absolute rather than relative
|
|
159
|
+
// to the current directory.
|
|
160
|
+
let normalized =
|
|
161
|
+
path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path;
|
|
162
|
+
if (!normalized.startsWith('/')) {
|
|
163
|
+
normalized = '/' + normalized;
|
|
164
|
+
}
|
|
165
|
+
// Collapse any double slashes that may result from the above transforms.
|
|
166
|
+
normalized = normalized.replace(/\/\/+/g, '/');
|
|
167
|
+
this._submittedLocalPath = this._model.manager.services.contents.localPath(
|
|
168
|
+
normalized || '/'
|
|
169
|
+
);
|
|
170
|
+
// Hide suggestions immediately so the input looks committed.
|
|
171
|
+
this._suggestionsNode.style.display = 'none';
|
|
172
|
+
this._model
|
|
173
|
+
.cd(normalized || '/')
|
|
174
|
+
.then(() => this._close())
|
|
175
|
+
.catch(error => {
|
|
176
|
+
this._submittedLocalPath = null;
|
|
177
|
+
void showErrorMessage(this._trans.__('Open Error'), error);
|
|
178
|
+
this._close();
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether the refreshed path corresponds to the last submitted path.
|
|
184
|
+
*/
|
|
185
|
+
matchesSubmittedPath(localPath: string): boolean {
|
|
186
|
+
return this._submittedLocalPath === localPath;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Fetch and display directory suggestions for the given input value.
|
|
191
|
+
*/
|
|
192
|
+
private async _updateSuggestions(inputValue: string): Promise<void> {
|
|
193
|
+
if (!this._isOpen) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const lastSlash = inputValue.lastIndexOf('/');
|
|
198
|
+
const rawDirPart = lastSlash >= 0 ? inputValue.slice(0, lastSlash) : '';
|
|
199
|
+
// Normalize to a path relative to the Jupyter server root by stripping
|
|
200
|
+
// any leading slash.
|
|
201
|
+
const dirPart = rawDirPart.startsWith('/')
|
|
202
|
+
? rawDirPart.slice(1)
|
|
203
|
+
: rawDirPart;
|
|
204
|
+
const searchPart =
|
|
205
|
+
lastSlash >= 0 ? inputValue.slice(lastSlash + 1) : inputValue;
|
|
206
|
+
|
|
207
|
+
// Re-fetch when the directory changes or the cache has gone stale.
|
|
208
|
+
const cacheStale =
|
|
209
|
+
Date.now() - this._suggestionFetchTime > SUGGESTION_CACHE_TTL_MS;
|
|
210
|
+
if (dirPart !== this._suggestionDirPath || cacheStale) {
|
|
211
|
+
const fetchId = ++this._fetchId;
|
|
212
|
+
try {
|
|
213
|
+
const contents = this._model.manager.services.contents;
|
|
214
|
+
const result = await contents.get(dirPart || '', { content: true });
|
|
215
|
+
// Discard result if a newer fetch has started or the widget was closed.
|
|
216
|
+
if (fetchId !== this._fetchId || !this._isOpen) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this._suggestionDirPath = dirPart;
|
|
220
|
+
this._suggestionFetchTime = Date.now();
|
|
221
|
+
const items =
|
|
222
|
+
result.type === 'directory'
|
|
223
|
+
? (result.content as Array<{ name: string; type: string }>)
|
|
224
|
+
: [];
|
|
225
|
+
this._suggestions = items
|
|
226
|
+
.filter(item => item.type === 'directory')
|
|
227
|
+
.map(item => (dirPart ? `${dirPart}/${item.name}` : item.name));
|
|
228
|
+
} catch {
|
|
229
|
+
this._suggestionDirPath = dirPart;
|
|
230
|
+
this._suggestionFetchTime = Date.now();
|
|
231
|
+
this._suggestions = [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const lower = searchPart.toLowerCase();
|
|
236
|
+
const showHidden =
|
|
237
|
+
searchPart.startsWith('.') ||
|
|
238
|
+
('includeHiddenFiles' in this._model &&
|
|
239
|
+
(this._model as TogglableHiddenFileBrowserModel).includeHiddenFiles);
|
|
240
|
+
const filtered = this._suggestions.filter(s => {
|
|
241
|
+
const base = s.slice(s.lastIndexOf('/') + 1);
|
|
242
|
+
if (!showHidden && base.startsWith('.')) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
return base.toLowerCase().startsWith(lower);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
this._activeSuggestionIndex = -1;
|
|
249
|
+
this._renderSuggestions(
|
|
250
|
+
filtered.slice().sort((a, b) => {
|
|
251
|
+
const nameA = a.slice(a.lastIndexOf('/') + 1);
|
|
252
|
+
const nameB = b.slice(b.lastIndexOf('/') + 1);
|
|
253
|
+
return nameA.localeCompare(nameB);
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Re-render the suggestions list from the given paths.
|
|
260
|
+
*/
|
|
261
|
+
private _renderSuggestions(suggestions: string[]): void {
|
|
262
|
+
this._suggestionsNode.replaceChildren();
|
|
263
|
+
this._currentFilteredSuggestions = suggestions;
|
|
264
|
+
if (suggestions.length === 0) {
|
|
265
|
+
this._suggestionsNode.style.display = 'none';
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
for (const path of suggestions) {
|
|
269
|
+
const li = document.createElement('li');
|
|
270
|
+
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
271
|
+
li.textContent = name;
|
|
272
|
+
li.dataset.path = path;
|
|
273
|
+
if (name.startsWith('.')) {
|
|
274
|
+
li.dataset.isDot = '';
|
|
275
|
+
}
|
|
276
|
+
this._suggestionsNode.appendChild(li);
|
|
277
|
+
}
|
|
278
|
+
this._suggestionsNode.style.display = '';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Handle keyboard navigation and confirmation inside the input.
|
|
283
|
+
*/
|
|
284
|
+
private _evtKeydown(event: KeyboardEvent): void {
|
|
285
|
+
switch (event.key) {
|
|
286
|
+
case 'Enter':
|
|
287
|
+
this._commitNavigation(this._inputNode.value);
|
|
288
|
+
break;
|
|
289
|
+
case 'Escape':
|
|
290
|
+
this._close();
|
|
291
|
+
break;
|
|
292
|
+
case 'Tab':
|
|
293
|
+
// Only prevent default Tab behavior when there is a suggestion to accept.
|
|
294
|
+
if (this._currentFilteredSuggestions?.length) {
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
this._acceptSuggestion();
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
case 'ArrowDown':
|
|
300
|
+
event.preventDefault();
|
|
301
|
+
this._navigateSuggestions(1);
|
|
302
|
+
break;
|
|
303
|
+
case 'ArrowUp':
|
|
304
|
+
event.preventDefault();
|
|
305
|
+
this._navigateSuggestions(-1);
|
|
306
|
+
break;
|
|
307
|
+
// `/` should be use to commit navigation to a new folder while the user
|
|
308
|
+
// types. It just happens for current implementation to not need anything.
|
|
309
|
+
// case '/'
|
|
310
|
+
// break
|
|
311
|
+
default:
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Handle mousedown on a suggestion item.
|
|
318
|
+
*
|
|
319
|
+
* Using mousedown (before blur) and calling preventDefault() keeps focus on
|
|
320
|
+
* the input, so we can navigate without the blur handler firing first.
|
|
321
|
+
*/
|
|
322
|
+
private _evtSuggestionMousedown(event: MouseEvent): void {
|
|
323
|
+
// Prevent the input from losing focus before we process the selection.
|
|
324
|
+
event.preventDefault();
|
|
325
|
+
let target = event.target as HTMLElement;
|
|
326
|
+
while (target && target !== this._suggestionsNode) {
|
|
327
|
+
if (target.tagName === 'LI') {
|
|
328
|
+
const path = target.dataset.path;
|
|
329
|
+
if (path) {
|
|
330
|
+
this._commitNavigation(path);
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
target = target.parentElement as HTMLElement;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Move the active suggestion up or down by `direction` steps.
|
|
340
|
+
*/
|
|
341
|
+
private _navigateSuggestions(direction: 1 | -1): void {
|
|
342
|
+
const items = Array.from(this._suggestionsNode.children) as HTMLElement[];
|
|
343
|
+
if (items.length === 0) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (this._activeSuggestionIndex >= 0) {
|
|
348
|
+
items[this._activeSuggestionIndex].classList.remove('jp-mod-active');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
this._activeSuggestionIndex += direction;
|
|
352
|
+
if (this._activeSuggestionIndex < 0) {
|
|
353
|
+
this._activeSuggestionIndex = items.length - 1;
|
|
354
|
+
} else if (this._activeSuggestionIndex >= items.length) {
|
|
355
|
+
this._activeSuggestionIndex = 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const activeItem = items[this._activeSuggestionIndex];
|
|
359
|
+
activeItem.classList.add('jp-mod-active');
|
|
360
|
+
activeItem.scrollIntoView({ block: 'nearest' });
|
|
361
|
+
|
|
362
|
+
const path = activeItem.dataset.path;
|
|
363
|
+
if (path) {
|
|
364
|
+
// It is tempting to append / here, though not appending it allows us to
|
|
365
|
+
// use this key (`/`) as committing navigation in the pathnavigator and
|
|
366
|
+
// keep typing, and showing completion while Enter/Return validate the
|
|
367
|
+
// breadcrumb level. Appending / here feels awkward when used in
|
|
368
|
+
// practice.
|
|
369
|
+
// Note that (`/`) is not handled specifically in `_evtKeydown`, as the
|
|
370
|
+
// codepath is the same as default;
|
|
371
|
+
this._inputNode.value = path;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Accept the highlighted suggestion (Tab key).
|
|
377
|
+
* If none is highlighted, complete to the sole match or longest common prefix.
|
|
378
|
+
*/
|
|
379
|
+
private _acceptSuggestion(): void {
|
|
380
|
+
const items = Array.from(this._suggestionsNode.children) as HTMLElement[];
|
|
381
|
+
|
|
382
|
+
if (
|
|
383
|
+
this._activeSuggestionIndex >= 0 &&
|
|
384
|
+
items[this._activeSuggestionIndex]
|
|
385
|
+
) {
|
|
386
|
+
const path = items[this._activeSuggestionIndex].dataset.path;
|
|
387
|
+
if (path) {
|
|
388
|
+
this._inputNode.value = path + '/';
|
|
389
|
+
void this._updateSuggestions(this._inputNode.value);
|
|
390
|
+
}
|
|
391
|
+
} else if (this._currentFilteredSuggestions.length === 1) {
|
|
392
|
+
this._inputNode.value = this._currentFilteredSuggestions[0] + '/';
|
|
393
|
+
void this._updateSuggestions(this._inputNode.value);
|
|
394
|
+
} else if (this._currentFilteredSuggestions.length > 1) {
|
|
395
|
+
// Complete to the longest common prefix of all matching names.
|
|
396
|
+
const names = this._currentFilteredSuggestions.map(s =>
|
|
397
|
+
s.slice(s.lastIndexOf('/') + 1)
|
|
398
|
+
);
|
|
399
|
+
let prefix = names[0];
|
|
400
|
+
for (const name of names.slice(1)) {
|
|
401
|
+
let i = 0;
|
|
402
|
+
while (i < prefix.length && i < name.length && prefix[i] === name[i]) {
|
|
403
|
+
i++;
|
|
404
|
+
}
|
|
405
|
+
prefix = prefix.slice(0, i);
|
|
406
|
+
}
|
|
407
|
+
if (prefix) {
|
|
408
|
+
const lastSlash = this._inputNode.value.lastIndexOf('/');
|
|
409
|
+
const dirPart =
|
|
410
|
+
lastSlash >= 0 ? this._inputNode.value.slice(0, lastSlash + 1) : '';
|
|
411
|
+
this._inputNode.value = dirPart + prefix;
|
|
412
|
+
void this._updateSuggestions(this._inputNode.value);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Handle the model's `refreshed` signal.
|
|
419
|
+
* Invalidate the suggestion cache so the next lookup fetches fresh data.
|
|
420
|
+
* If the input is currently open, proactively re-fetch suggestions.
|
|
421
|
+
*/
|
|
422
|
+
private _onModelRefreshed(): void {
|
|
423
|
+
this._suggestionFetchTime = 0;
|
|
424
|
+
if (this._isOpen) {
|
|
425
|
+
void this._updateSuggestions(this._inputNode.value);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private _model: FileBrowserModel;
|
|
430
|
+
private _trans: TranslationBundle;
|
|
431
|
+
private _inputNode: HTMLInputElement;
|
|
432
|
+
private _suggestionsNode: HTMLElement;
|
|
433
|
+
private _closed = new Signal<this, void>(this);
|
|
434
|
+
private _isOpen = false;
|
|
435
|
+
private _suggestions: string[] = [];
|
|
436
|
+
private _currentFilteredSuggestions: string[] = [];
|
|
437
|
+
private _activeSuggestionIndex = -1;
|
|
438
|
+
private _suggestionDirPath = '';
|
|
439
|
+
private _suggestionFetchTime = 0;
|
|
440
|
+
private _fetchId = 0;
|
|
441
|
+
private _submittedLocalPath: string | null = null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export namespace PathNavigator {
|
|
445
|
+
export interface IOptions {
|
|
446
|
+
/**
|
|
447
|
+
* The file browser model.
|
|
448
|
+
*/
|
|
449
|
+
model: FileBrowserModel;
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The application language translator.
|
|
453
|
+
*/
|
|
454
|
+
translator?: ITranslator;
|
|
455
|
+
}
|
|
456
|
+
}
|