UNPKG

@jupyterlab/filebrowser

Version:
389 lines 15.8 kB
// 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