UNPKG

@jupyterlab/notebook

Version:
723 lines 30.5 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { Dialog, showDialog } from '@jupyterlab/apputils'; import { CodeCell, createCellSearchProvider } from '@jupyterlab/cells'; import { SearchProvider } from '@jupyterlab/documentsearch'; import { nullTranslator } from '@jupyterlab/translation'; import { ArrayExt } from '@lumino/algorithm'; import { NotebookPanel } from './panel'; /** * Notebook document search provider */ export class NotebookSearchProvider extends SearchProvider { /** * Constructor * * @param widget The widget to search in * @param translator Application translator */ constructor(widget, translator = nullTranslator) { super(widget); this.translator = translator; this._textSelection = null; this._currentProviderIndex = null; this._delayedActiveCellChangeHandler = null; this._onSelection = false; this._selectedCells = 1; this._selectedLines = 0; this._query = null; this._searchProviders = []; this._editorSelectionsObservable = null; this._selectionSearchMode = 'cells'; this._selectionLock = false; this._searchActive = false; this._handleHighlightsAfterActiveCellChange = this._handleHighlightsAfterActiveCellChange.bind(this); this.widget.model.cells.changed.connect(this._onCellsChanged, this); this.widget.content.activeCellChanged.connect(this._onActiveCellChanged, this); this.widget.content.selectionChanged.connect(this._onCellSelectionChanged, this); this.widget.content.stateChanged.connect(this._onNotebookStateChanged, this); this._observeActiveCell(); this._filtersChanged.connect(this._setEnginesSelectionSearchMode, this); } _onNotebookStateChanged(_, args) { if (args.name === 'mode') { // Delay the update to ensure that `document.activeElement` settled. window.setTimeout(() => { var _a; if (args.newValue === 'command' && ((_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.closest('.jp-DocumentSearch-overlay'))) { // Do not request updating mode when user switched focus to search overlay. return; } this._updateSelectionMode(); this._filtersChanged.emit(); }, 0); } } /** * Report whether or not this provider has the ability to search on the given object * * @param domain Widget to test * @returns Search ability */ static isApplicable(domain) { // check to see if the CMSearchProvider can search on the // first cell, false indicates another editor is present return domain instanceof NotebookPanel; } /** * Instantiate a search provider for the notebook panel. * * #### Notes * The widget provided is always checked using `isApplicable` before calling * this factory. * * @param widget The widget to search on * @param translator [optional] The translator object * * @returns The search provider on the notebook panel */ static createNew(widget, translator) { return new NotebookSearchProvider(widget, translator); } /** * The current index of the selected match. */ get currentMatchIndex() { let agg = 0; let found = false; for (let idx = 0; idx < this._searchProviders.length; idx++) { const provider = this._searchProviders[idx]; if (this._currentProviderIndex == idx) { const localMatch = provider.currentMatchIndex; if (localMatch === null) { return null; } agg += localMatch; found = true; break; } else { agg += provider.matchesCount; } } return found ? agg : null; } /** * The number of matches. */ get matchesCount() { return this._searchProviders.reduce((sum, provider) => (sum += provider.matchesCount), 0); } /** * Set to true if the widget under search is read-only, false * if it is editable. Will be used to determine whether to show * the replace option. */ get isReadOnly() { var _a, _b, _c; return (_c = (_b = (_a = this.widget) === null || _a === void 0 ? void 0 : _a.content.model) === null || _b === void 0 ? void 0 : _b.readOnly) !== null && _c !== void 0 ? _c : false; } /** * Support for options adjusting replacement behavior. */ get replaceOptionsSupport() { return { preserveCase: true }; } getSelectionState() { const cellMode = this._selectionSearchMode === 'cells'; const selectedCount = cellMode ? this._selectedCells : this._selectedLines; return selectedCount > 1 ? 'multiple' : selectedCount === 1 && !cellMode ? 'single' : 'none'; } /** * Dispose of the resources held by the search provider. * * #### Notes * If the object's `dispose` method is called more than once, all * calls made after the first will be a no-op. * * #### Undefined Behavior * It is undefined behavior to use any functionality of the object * after it has been disposed unless otherwise explicitly noted. */ dispose() { var _a; if (this.isDisposed) { return; } this.widget.content.activeCellChanged.disconnect(this._onActiveCellChanged, this); (_a = this.widget.model) === null || _a === void 0 ? void 0 : _a.cells.changed.disconnect(this._onCellsChanged, this); this.widget.content.stateChanged.disconnect(this._onNotebookStateChanged, this); this.widget.content.selectionChanged.disconnect(this._onCellSelectionChanged, this); this._stopObservingLastCell(); super.dispose(); const index = this.widget.content.activeCellIndex; this.endQuery() .then(() => { if (!this.widget.isDisposed) { this.widget.content.activeCellIndex = index; } }) .catch(reason => { console.error(`Fail to end search query in notebook:\n${reason}`); }); } /** * Get the filters for the given provider. * * @returns The filters. */ getFilters() { const trans = this.translator.load('jupyterlab'); return { output: { title: trans.__('Search Cell Outputs'), description: trans.__('Search in the cell outputs.'), disabledDescription: trans.__('Search in the cell outputs (not available when replace options are shown).'), default: false, supportReplace: false }, selection: { title: this._selectionSearchMode === 'cells' ? trans._n('Search in %1 Selected Cell', 'Search in %1 Selected Cells', this._selectedCells) : trans._n('Search in %1 Selected Line', 'Search in %1 Selected Lines', this._selectedLines), description: trans.__('Search only in the selected cells or text (depending on edit/command mode).'), default: false, supportReplace: true } }; } /** * Update the search in selection mode; it should only be called when user * navigates the notebook (enters editing/command mode, changes selection) * but not when the searchbox gets focused (switching the notebook to command * mode) nor when search highlights a match (switching notebook to edit mode). */ _updateSelectionMode() { if (this._selectionLock) { return; } this._selectionSearchMode = this._selectedCells === 1 && this.widget.content.mode === 'edit' && this._selectedLines !== 0 ? 'text' : 'cells'; } /** * Get an initial query value if applicable so that it can be entered * into the search box as an initial query * * @returns Initial value used to populate the search box. */ getInitialQuery() { var _a; // Get whatever is selected in the browser window. return ((_a = window.getSelection()) === null || _a === void 0 ? void 0 : _a.toString()) || ''; } /** * Clear currently highlighted match. */ async clearHighlight() { this._selectionLock = true; if (this._currentProviderIndex !== null && this._currentProviderIndex < this._searchProviders.length) { await this._searchProviders[this._currentProviderIndex].clearHighlight(); this._currentProviderIndex = null; } this._selectionLock = false; } /** * Highlight the next match. * * @param loop Whether to loop within the matches list. * * @returns The next match if available. */ async highlightNext(loop = true, options) { const match = await this._stepNext(false, loop, options); return match !== null && match !== void 0 ? match : undefined; } /** * Highlight the previous match. * * @param loop Whether to loop within the matches list. * * @returns The previous match if available. */ async highlightPrevious(loop = true, options) { const match = await this._stepNext(true, loop, options); return match !== null && match !== void 0 ? match : undefined; } /** * Search for a regular expression with optional filters. * * @param query A regular expression to test for * @param filters Filter parameters to pass to provider * */ async startQuery(query, filters) { if (!this.widget) { return; } await this.endQuery(); this._searchActive = true; let cells = this.widget.content.widgets; this._query = query; this._filters = { output: false, selection: false, ...(filters !== null && filters !== void 0 ? filters : {}) }; this._onSelection = this._filters.selection; const currentProviderIndex = this.widget.content.activeCellIndex; // For each cell, create a search provider this._searchProviders = await Promise.all(cells.map(async (cell, index) => { const cellSearchProvider = createCellSearchProvider(cell); await cellSearchProvider.setIsActive(!this._filters.selection || this.widget.content.isSelectedOrActive(cell)); if (this._onSelection && this._selectionSearchMode === 'text' && index === currentProviderIndex) { if (this._textSelection) { await cellSearchProvider.setSearchSelection(this._textSelection); } } await cellSearchProvider.startQuery(query, this._filters); return cellSearchProvider; })); this._currentProviderIndex = currentProviderIndex; // We do not want to show the first "current" closest to cursor as depending // on which way the user dragged the selection it would be: // - the first or last match when searching in selection // - the next match when starting search using ctrl + f // `scroll` and `select` are disabled because `startQuery` is also used as // "restartQuery" after each text change and if those were enabled, we would // steal the cursor. await this.highlightNext(true, { from: 'selection-start', scroll: false, select: false }); return Promise.resolve(); } /** * Stop the search and clear all internal state. */ async endQuery() { await Promise.all(this._searchProviders.map(provider => { return provider.endQuery().then(() => { provider.dispose(); }); })); this._searchActive = false; this._searchProviders.length = 0; this._currentProviderIndex = null; } /** * Replace the currently selected match with the provided text * * @param newText The replacement text. * @param loop Whether to loop within the matches list. * * @returns A promise that resolves with a boolean indicating whether a replace occurred. */ async replaceCurrentMatch(newText, loop = true, options) { let replaceOccurred = false; const unrenderMarkdownCell = async (highlightNext = false) => { var _a; // Unrendered markdown cell const activeCell = (_a = this.widget) === null || _a === void 0 ? void 0 : _a.content.activeCell; if ((activeCell === null || activeCell === void 0 ? void 0 : activeCell.model.type) === 'markdown' && activeCell.rendered) { activeCell.rendered = false; if (highlightNext) { await this.highlightNext(loop); } } }; if (this._currentProviderIndex !== null) { await unrenderMarkdownCell(); const searchEngine = this._searchProviders[this._currentProviderIndex]; replaceOccurred = await searchEngine.replaceCurrentMatch(newText, false, options); if (searchEngine.currentMatchIndex === null) { // switch to next cell await this.highlightNext(loop, { from: 'previous-match' }); } } // TODO: markdown unrendering/highlighting sequence is likely incorrect // Force highlighting the first hit in the unrendered cell await unrenderMarkdownCell(true); return replaceOccurred; } /** * Replace all matches in the notebook with the provided text * * @param newText The replacement text. * * @returns A promise that resolves with a boolean indicating whether a replace occurred. */ async replaceAllMatches(newText, options) { const replacementOccurred = await Promise.all(this._searchProviders.map(provider => { return provider.replaceAllMatches(newText, options); })); return replacementOccurred.includes(true); } async validateFilter(name, value) { if (name !== 'output') { // Bail early return value; } // If value is true and some cells have never been rendered, ask confirmation. if (value && this.widget.content.widgets.some(w => w instanceof CodeCell && w.isPlaceholder())) { const trans = this.translator.load('jupyterlab'); const reply = await showDialog({ title: trans.__('Confirmation'), body: trans.__('Searching outputs requires you to run all cells and render their outputs. Are you sure you want to search in the cell outputs?'), buttons: [ Dialog.cancelButton({ label: trans.__('Cancel') }), Dialog.okButton({ label: trans.__('Ok') }) ] }); if (reply.button.accept) { this.widget.content.widgets.forEach((w, i) => { if (w instanceof CodeCell && w.isPlaceholder()) { this.widget.content.renderCellOutputs(i); } }); } else { return false; } } return value; } _addCellProvider(index) { var _a, _b; const cell = this.widget.content.widgets[index]; const cellSearchProvider = createCellSearchProvider(cell); ArrayExt.insert(this._searchProviders, index, cellSearchProvider); void cellSearchProvider .setIsActive(!((_b = (_a = this._filters) === null || _a === void 0 ? void 0 : _a.selection) !== null && _b !== void 0 ? _b : false) || this.widget.content.isSelectedOrActive(cell)) .then(() => { if (this._searchActive) { void cellSearchProvider.startQuery(this._query, this._filters); } }); } _removeCellProvider(index) { const provider = ArrayExt.removeAt(this._searchProviders, index); provider === null || provider === void 0 ? void 0 : provider.dispose(); } async _onCellsChanged(cells, changes) { switch (changes.type) { case 'add': changes.newValues.forEach((model, index) => { this._addCellProvider(changes.newIndex + index); }); break; case 'move': ArrayExt.move(this._searchProviders, changes.oldIndex, changes.newIndex); break; case 'remove': for (let index = 0; index < changes.oldValues.length; index++) { this._removeCellProvider(changes.oldIndex); } break; case 'set': changes.newValues.forEach((model, index) => { this._addCellProvider(changes.newIndex + index); this._removeCellProvider(changes.newIndex + index + 1); }); break; case 'clear': for (let index = this._searchProviders.length - 1; index >= 0; index--) { this._removeCellProvider(index); } break; } this._stateChanged.emit(); } async _stepNext(reverse = false, loop = false, options) { var _a; const activateNewMatch = async (match) => { var _a; const shouldScroll = (_a = options === null || options === void 0 ? void 0 : options.scroll) !== null && _a !== void 0 ? _a : true; if (!shouldScroll) { // do not activate the match if scrolling was disabled return; } this._selectionLock = true; if (this.widget.content.activeCellIndex !== this._currentProviderIndex) { this.widget.content.activeCellIndex = this._currentProviderIndex; } if (this.widget.content.activeCellIndex === -1) { console.warn('No active cell (no cells or no model), aborting search'); this._selectionLock = false; return; } const activeCell = this.widget.content.activeCell; if (!activeCell.inViewport) { try { await this.widget.content.scrollToItem(this._currentProviderIndex); } catch (error) { // no-op } } // Unhide cell if (activeCell.inputHidden) { activeCell.inputHidden = false; } if (!activeCell.inViewport) { this._selectionLock = false; // It will not be possible the cell is not in the view return; } await activeCell.ready; const editor = activeCell.editor; editor.revealPosition(editor.getPositionAt(match.position)); this._selectionLock = false; }; if (this._currentProviderIndex === null) { this._currentProviderIndex = this.widget.content.activeCellIndex; } // When going to previous match in cell mode and there is no current we // want to skip the active cell and go to the previous cell; in edit mode // the appropriate behaviour is induced by searching from nearest cursor. if (reverse && this.widget.content.mode === 'command') { const searchEngine = this._searchProviders[this._currentProviderIndex]; const currentMatch = searchEngine.getCurrentMatch(); if (!currentMatch) { this._currentProviderIndex -= 1; } if (loop) { this._currentProviderIndex = (this._currentProviderIndex + this._searchProviders.length) % this._searchProviders.length; } } // If we're looking for the next match after the previous match, // and we've reached the end of the current cell, start at the next one, if possible const from = (_a = options === null || options === void 0 ? void 0 : options.from) !== null && _a !== void 0 ? _a : ''; const atEndOfCurrentCell = from === 'previous-match' && this._searchProviders[this._currentProviderIndex].currentMatchIndex === null; const startIndex = this._currentProviderIndex; // If we need to move to the next cell or loop, reset the position of the current search provider. if (atEndOfCurrentCell) { void this._searchProviders[this._currentProviderIndex].clearHighlight(); } // If we're at the end of the last cell in the provider list and we need to loop, do so if (loop && atEndOfCurrentCell && this._currentProviderIndex + 1 >= this._searchProviders.length) { this._currentProviderIndex = 0; } else { this._currentProviderIndex += atEndOfCurrentCell ? 1 : 0; } do { const searchEngine = this._searchProviders[this._currentProviderIndex]; const match = reverse ? await searchEngine.highlightPrevious(false, options) : await searchEngine.highlightNext(false, options); if (match) { await activateNewMatch(match); return match; } else { this._currentProviderIndex = this._currentProviderIndex + (reverse ? -1 : 1); if (loop) { this._currentProviderIndex = (this._currentProviderIndex + this._searchProviders.length) % this._searchProviders.length; } } } while (loop ? // We looped on all cells, no hit found this._currentProviderIndex !== startIndex : 0 <= this._currentProviderIndex && this._currentProviderIndex < this._searchProviders.length); if (loop) { // try the first provider again const searchEngine = this._searchProviders[startIndex]; const match = reverse ? await searchEngine.highlightPrevious(false, options) : await searchEngine.highlightNext(false, options); if (match) { await activateNewMatch(match); return match; } } this._currentProviderIndex = null; return null; } async _onActiveCellChanged() { if (this._delayedActiveCellChangeHandler !== null) { // Prevent handler from running twice if active cell is changed twice // within the same task of the event loop. clearTimeout(this._delayedActiveCellChangeHandler); this._delayedActiveCellChangeHandler = null; } if (this.widget.content.activeCellIndex !== this._currentProviderIndex) { // At this time we cannot handle the change of active cell, because // `activeCellChanged` is also emitted in the middle of cell selection // change, and if selection is getting extended, we do not want to clear // highlights just to re-apply them shortly after, which has side effects // impacting the functionality and performance. this._delayedActiveCellChangeHandler = window.setTimeout(() => { this.delayedActiveCellChangeHandlerReady = this._handleHighlightsAfterActiveCellChange(); }, 0); } this._observeActiveCell(); } async _handleHighlightsAfterActiveCellChange() { if (this._onSelection) { const previousProviderCell = this._currentProviderIndex !== null && this._currentProviderIndex < this.widget.content.widgets.length ? this.widget.content.widgets[this._currentProviderIndex] : null; const previousProviderInCurrentSelection = previousProviderCell && this.widget.content.isSelectedOrActive(previousProviderCell); if (!previousProviderInCurrentSelection) { await this._updateCellSelection(); // Clear highlight from previous provider await this.clearHighlight(); // If we are searching in all cells, we should not change the active // provider when switching active cell to preserve current match; // if we are searching within selected cells we should update this._currentProviderIndex = this.widget.content.activeCellIndex; } } await this._ensureCurrentMatch(); } /** * If there are results but no match is designated as current, * mark a result as current and highlight it. */ async _ensureCurrentMatch() { if (this._currentProviderIndex !== null) { const searchEngine = this._searchProviders[this._currentProviderIndex]; if (!searchEngine) { // This can happen when `startQuery()` has not finished yet. return; } const currentMatch = searchEngine.getCurrentMatch(); if (!currentMatch && this.matchesCount) { // Select a match as current by highlighting next (with looping) from // the selection start, to prevent "current" match from jumping around. await this.highlightNext(true, { from: 'start', scroll: false, select: false }); } } } _observeActiveCell() { var _a; const editor = (_a = this.widget.content.activeCell) === null || _a === void 0 ? void 0 : _a.editor; if (!editor) { return; } this._stopObservingLastCell(); editor.model.selections.changed.connect(this._setSelectedLines, this); this._editorSelectionsObservable = editor.model.selections; } _stopObservingLastCell() { if (this._editorSelectionsObservable) { this._editorSelectionsObservable.changed.disconnect(this._setSelectedLines, this); } } _setSelectedLines() { var _a; const editor = (_a = this.widget.content.activeCell) === null || _a === void 0 ? void 0 : _a.editor; if (!editor) { return; } const selection = editor.getSelection(); const { start, end } = selection; const newLines = end.line === start.line && end.column === start.column ? 0 : end.line - start.line + 1; this._textSelection = selection; if (newLines !== this._selectedLines) { this._selectedLines = newLines; this._updateSelectionMode(); } this._filtersChanged.emit(); } /** * Set whether the engines should search within selection only or full text. */ async _setEnginesSelectionSearchMode() { let textMode; if (!this._onSelection) { // When search in selection is off we always search full text textMode = false; } else { // When search in selection is off we either search in full cells // (toggling off isActive flag on search engines of non-selected cells) // or in selected text of the active cell. textMode = this._selectionSearchMode === 'text'; } if (this._selectionLock) { return; } // Clear old selection restrictions or if relevant, set current restrictions for active provider. await Promise.all(this._searchProviders.map((provider, index) => { const isCurrent = this.widget.content.activeCellIndex === index; provider.setProtectSelection(isCurrent && this._onSelection); return provider.setSearchSelection(isCurrent && textMode ? this._textSelection : null); })); } async _onCellSelectionChanged() { if (this._delayedActiveCellChangeHandler !== null) { // Avoid race condition due to `activeCellChanged` and `selectionChanged` // signals firing in short sequence when selection gets extended, with // handling of the former having potential to undo selection set by the latter. clearTimeout(this._delayedActiveCellChangeHandler); this._delayedActiveCellChangeHandler = null; } await this._updateCellSelection(); if (this._currentProviderIndex === null) { // For consistency we set the first cell in selection as current provider. const firstSelectedCellIndex = this.widget.content.widgets.findIndex(cell => this.widget.content.isSelectedOrActive(cell)); this._currentProviderIndex = firstSelectedCellIndex; } await this._ensureCurrentMatch(); } async _updateCellSelection() { const cells = this.widget.content.widgets; let selectedCells = 0; await Promise.all(cells.map(async (cell, index) => { const provider = this._searchProviders[index]; const isSelected = this.widget.content.isSelectedOrActive(cell); if (isSelected) { selectedCells += 1; } if (provider && this._onSelection) { await provider.setIsActive(isSelected); } })); if (selectedCells !== this._selectedCells) { this._selectedCells = selectedCells; this._updateSelectionMode(); } this._filtersChanged.emit(); } } //# sourceMappingURL=searchprovider.js.map