@jupyterlab/filebrowser
Version:
JupyterLab - FileBrowser Widget
389 lines • 15.8 kB
JavaScript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import { showErrorMessage } from '@jupyterlab/apputils';
import { nullTranslator } from '@jupyterlab/translation';
import { Signal } from '@lumino/signaling';
import { Widget } from '@lumino/widgets';
/**
* We cache per directory; in case the filesystem changes below us, we refresh
* every 5000 ms just in case; Note that on model update it should refresh as
* well; so this is extra precautions.
*/
const SUGGESTION_CACHE_TTL_MS = 5000;
const PATHNAVIGATOR_CLASS = 'jp-PathNavigator';
const PATHNAVIGATOR_SUGGESTIONS_CLASS = 'jp-PathNavigator-suggestions';
/**
* A widget that renders a path text input with directory autocomplete.
* It owns only the input field and the suggestions dropdown.
* The trigger button and edit-mode state are managed by the parent widget.
*/
export class PathNavigator extends Widget {
constructor(options) {
var _a;
super({ node: document.createElement('span') });
this._closed = new Signal(this);
this._isOpen = false;
this._suggestions = [];
this._currentFilteredSuggestions = [];
this._activeSuggestionIndex = -1;
this._suggestionDirPath = '';
this._suggestionFetchTime = 0;
this._fetchId = 0;
this._submittedLocalPath = null;
this.addClass(PATHNAVIGATOR_CLASS);
this._model = options.model;
this._trans = ((_a = options.translator) !== null && _a !== void 0 ? _a : nullTranslator).load('jupyterlab');
this._inputNode = document.createElement('input');
this._inputNode.type = 'text';
this._inputNode.placeholder = this._trans.__('Type a path…');
this._suggestionsNode = document.createElement('ul');
this._suggestionsNode.className = PATHNAVIGATOR_SUGGESTIONS_CLASS;
this._suggestionsNode.style.display = 'none';
this.node.appendChild(this._inputNode);
this.node.appendChild(this._suggestionsNode);
this._model.refreshed.connect(this._onModelRefreshed, this);
}
/**
* A signal emitted when the navigator closes (Escape, blur, or after
* navigation is committed). The parent widget should use this to exit
* edit mode.
*/
get closed() {
return this._closed;
}
/**
* Dispose of the resources held by the widget.
*/
dispose() {
if (this.isDisposed) {
return;
}
this._model.refreshed.disconnect(this._onModelRefreshed, this);
Signal.clearData(this);
super.dispose();
}
/**
* Open the path input: prefill with the model's current path, focus,
* and load suggestions.
*/
open() {
this._isOpen = true;
const contents = this._model.manager.services.contents;
const currentPath = contents.localPath(this._model.path);
const prefill = currentPath ? currentPath + '/' : '';
this._inputNode.value = prefill;
// Defer focus so that callers (e.g. command palette) can finish their
// own focus cleanup before we grab focus. Without this, the palette's
// closing logic can steal focus back, triggering the blur→close handler
// and immediately exiting edit mode.
requestAnimationFrame(() => {
if (!this._isOpen || this.isDisposed) {
return;
}
this._inputNode.focus();
this._inputNode.setSelectionRange(prefill.length, prefill.length);
});
void this._updateSuggestions(prefill);
}
/**
* A message handler invoked on an `'after-attach'` message.
*/
onAfterAttach(msg) {
super.onAfterAttach(msg);
this._inputNode.addEventListener('input', this);
this._inputNode.addEventListener('keydown', this);
this._inputNode.addEventListener('blur', this);
// Use mousedown (not click) so we can preventDefault() before blur fires.
this._suggestionsNode.addEventListener('mousedown', this);
}
/**
* A message handler invoked on a `'before-detach'` message.
*/
onBeforeDetach(msg) {
this._inputNode.removeEventListener('input', this);
this._inputNode.removeEventListener('keydown', this);
this._inputNode.removeEventListener('blur', this);
this._suggestionsNode.removeEventListener('mousedown', this);
super.onBeforeDetach(msg);
}
handleEvent(event) {
switch (event.type) {
case 'input':
void this._updateSuggestions(this._inputNode.value);
break;
case 'keydown':
this._evtKeydown(event);
break;
case 'blur':
this._close();
break;
case 'mousedown':
this._evtSuggestionMousedown(event);
break;
default:
break;
}
}
/**
* Close the input and notify the parent via the `closed` signal.
*/
_close() {
if (!this._isOpen || this.isDisposed) {
return;
}
this._isOpen = false;
this._submittedLocalPath = null;
this._suggestionsNode.style.display = 'none';
this._closed.emit();
}
/**
* Navigate to the given path, then close the input.
*/
_commitNavigation(path) {
// Strip trailing slash (except bare root), then ensure a leading slash so
// model.cd() → resolvePath() treats this as absolute rather than relative
// to the current directory.
let normalized = path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path;
if (!normalized.startsWith('/')) {
normalized = '/' + normalized;
}
// Collapse any double slashes that may result from the above transforms.
normalized = normalized.replace(/\/{2,}/g, '/');
this._submittedLocalPath = this._model.manager.services.contents.localPath(normalized || '/');
// Hide suggestions immediately so the input looks committed.
this._suggestionsNode.style.display = 'none';
this._model
.cd(normalized || '/')
.then(() => this._close())
.catch(error => {
this._submittedLocalPath = null;
void showErrorMessage(this._trans.__('Open Error'), error);
this._close();
});
}
/**
* Whether the refreshed path corresponds to the last submitted path.
*/
matchesSubmittedPath(localPath) {
return this._submittedLocalPath === localPath;
}
/**
* Fetch and display directory suggestions for the given input value.
*/
async _updateSuggestions(inputValue) {
if (!this._isOpen) {
return;
}
const lastSlash = inputValue.lastIndexOf('/');
const rawDirPart = lastSlash >= 0 ? inputValue.slice(0, lastSlash) : '';
// Normalize to a path relative to the Jupyter server root by stripping
// any leading slash.
const dirPart = rawDirPart.startsWith('/')
? rawDirPart.slice(1)
: rawDirPart;
const searchPart = lastSlash >= 0 ? inputValue.slice(lastSlash + 1) : inputValue;
// Re-fetch when the directory changes or the cache has gone stale.
const cacheStale = Date.now() - this._suggestionFetchTime > SUGGESTION_CACHE_TTL_MS;
if (dirPart !== this._suggestionDirPath || cacheStale) {
const fetchId = ++this._fetchId;
try {
const contents = this._model.manager.services.contents;
const result = await contents.get(dirPart || '', { content: true });
// Discard result if a newer fetch has started or the widget was closed.
if (fetchId !== this._fetchId || !this._isOpen) {
return;
}
this._suggestionDirPath = dirPart;
this._suggestionFetchTime = Date.now();
const items = result.type === 'directory'
? result.content
: [];
this._suggestions = items
.filter(item => item.type === 'directory')
.map(item => (dirPart ? `${dirPart}/${item.name}` : item.name));
}
catch (_a) {
this._suggestionDirPath = dirPart;
this._suggestionFetchTime = Date.now();
this._suggestions = [];
}
}
const lower = searchPart.toLowerCase();
const showHidden = searchPart.startsWith('.') ||
('includeHiddenFiles' in this._model &&
this._model.includeHiddenFiles);
const filtered = this._suggestions.filter(s => {
const base = s.slice(s.lastIndexOf('/') + 1);
if (!showHidden && base.startsWith('.')) {
return false;
}
return base.toLowerCase().startsWith(lower);
});
this._activeSuggestionIndex = -1;
this._renderSuggestions(filtered.slice().sort((a, b) => {
const nameA = a.slice(a.lastIndexOf('/') + 1);
const nameB = b.slice(b.lastIndexOf('/') + 1);
return nameA.localeCompare(nameB);
}));
}
/**
* Re-render the suggestions list from the given paths.
*/
_renderSuggestions(suggestions) {
this._suggestionsNode.replaceChildren();
this._currentFilteredSuggestions = suggestions;
if (suggestions.length === 0) {
this._suggestionsNode.style.display = 'none';
return;
}
for (const path of suggestions) {
const li = document.createElement('li');
const name = path.slice(path.lastIndexOf('/') + 1);
li.textContent = name;
li.dataset.path = path;
if (name.startsWith('.')) {
li.dataset.isDot = '';
}
this._suggestionsNode.appendChild(li);
}
this._suggestionsNode.style.display = '';
}
/**
* Handle keyboard navigation and confirmation inside the input.
*/
_evtKeydown(event) {
var _a;
switch (event.key) {
case 'Enter':
this._commitNavigation(this._inputNode.value);
break;
case 'Escape':
this._close();
break;
case 'Tab':
// Only prevent default Tab behavior when there is a suggestion to accept.
if ((_a = this._currentFilteredSuggestions) === null || _a === void 0 ? void 0 : _a.length) {
event.preventDefault();
this._acceptSuggestion();
}
break;
case 'ArrowDown':
event.preventDefault();
this._navigateSuggestions(1);
break;
case 'ArrowUp':
event.preventDefault();
this._navigateSuggestions(-1);
break;
// `/` should be use to commit navigation to a new folder while the user
// types. It just happens for current implementation to not need anything.
// case '/'
// break
default:
break;
}
}
/**
* Handle mousedown on a suggestion item.
*
* Using mousedown (before blur) and calling preventDefault() keeps focus on
* the input, so we can navigate without the blur handler firing first.
*/
_evtSuggestionMousedown(event) {
// Prevent the input from losing focus before we process the selection.
event.preventDefault();
let target = event.target;
while (target && target !== this._suggestionsNode) {
if (target.tagName === 'LI') {
const path = target.dataset.path;
if (path) {
this._commitNavigation(path);
}
return;
}
target = target.parentElement;
}
}
/**
* Move the active suggestion up or down by `direction` steps.
*/
_navigateSuggestions(direction) {
const items = Array.from(this._suggestionsNode.children);
if (items.length === 0) {
return;
}
if (this._activeSuggestionIndex >= 0) {
items[this._activeSuggestionIndex].classList.remove('jp-mod-active');
}
this._activeSuggestionIndex += direction;
if (this._activeSuggestionIndex < 0) {
this._activeSuggestionIndex = items.length - 1;
}
else if (this._activeSuggestionIndex >= items.length) {
this._activeSuggestionIndex = 0;
}
const activeItem = items[this._activeSuggestionIndex];
activeItem.classList.add('jp-mod-active');
activeItem.scrollIntoView({ block: 'nearest' });
const path = activeItem.dataset.path;
if (path) {
// It is tempting to append / here, though not appending it allows us to
// use this key (`/`) as committing navigation in the pathnavigator and
// keep typing, and showing completion while Enter/Return validate the
// breadcrumb level. Appending / here feels awkward when used in
// practice.
// Note that (`/`) is not handled specifically in `_evtKeydown`, as the
// codepath is the same as default;
this._inputNode.value = path;
}
}
/**
* Accept the highlighted suggestion (Tab key).
* If none is highlighted, complete to the sole match or longest common prefix.
*/
_acceptSuggestion() {
const items = Array.from(this._suggestionsNode.children);
if (this._activeSuggestionIndex >= 0 &&
items[this._activeSuggestionIndex]) {
const path = items[this._activeSuggestionIndex].dataset.path;
if (path) {
this._inputNode.value = path + '/';
void this._updateSuggestions(this._inputNode.value);
}
}
else if (this._currentFilteredSuggestions.length === 1) {
this._inputNode.value = this._currentFilteredSuggestions[0] + '/';
void this._updateSuggestions(this._inputNode.value);
}
else if (this._currentFilteredSuggestions.length > 1) {
// Complete to the longest common prefix of all matching names.
const names = this._currentFilteredSuggestions.map(s => s.slice(s.lastIndexOf('/') + 1));
let prefix = names[0];
for (const name of names.slice(1)) {
let i = 0;
while (i < prefix.length && i < name.length && prefix[i] === name[i]) {
i++;
}
prefix = prefix.slice(0, i);
}
if (prefix) {
const lastSlash = this._inputNode.value.lastIndexOf('/');
const dirPart = lastSlash >= 0 ? this._inputNode.value.slice(0, lastSlash + 1) : '';
this._inputNode.value = dirPart + prefix;
void this._updateSuggestions(this._inputNode.value);
}
}
}
/**
* Handle the model's `refreshed` signal.
* Invalidate the suggestion cache so the next lookup fetches fresh data.
* If the input is currently open, proactively re-fetch suggestions.
*/
_onModelRefreshed() {
this._suggestionFetchTime = 0;
if (this._isOpen) {
void this._updateSuggestions(this._inputNode.value);
}
}
}
//# sourceMappingURL=pathnavigator.js.map