UNPKG

28.7 kBJavaScriptView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3import { Dialog, showDialog } from '@jupyterlab/apputils';
4import { CodeCell, createCellSearchProvider } from '@jupyterlab/cells';
5import { SearchProvider } from '@jupyterlab/documentsearch';
6import { nullTranslator } from '@jupyterlab/translation';
7import { ArrayExt } from '@lumino/algorithm';
8import { NotebookPanel } from './panel';
9/**
10 * Notebook document search provider
11 */
12export class NotebookSearchProvider extends SearchProvider {
13 /**
14 * Constructor
15 *
16 * @param widget The widget to search in
17 * @param translator Application translator
18 */
19 constructor(widget, translator = nullTranslator) {
20 super(widget);
21 this.translator = translator;
22 this._textSelection = null;
23 this._currentProviderIndex = null;
24 this._delayedActiveCellChangeHandler = null;
25 this._onSelection = false;
26 this._selectedCells = 1;
27 this._selectedLines = 0;
28 this._query = null;
29 this._searchProviders = [];
30 this._editorSelectionsObservable = null;
31 this._selectionSearchMode = 'cells';
32 this._selectionLock = false;
33 this._handleHighlightsAfterActiveCellChange =
34 this._handleHighlightsAfterActiveCellChange.bind(this);
35 this.widget.model.cells.changed.connect(this._onCellsChanged, this);
36 this.widget.content.activeCellChanged.connect(this._onActiveCellChanged, this);
37 this.widget.content.selectionChanged.connect(this._onCellSelectionChanged, this);
38 this.widget.content.stateChanged.connect(this._onNotebookStateChanged, this);
39 this._observeActiveCell();
40 this._filtersChanged.connect(this._setEnginesSelectionSearchMode, this);
41 }
42 _onNotebookStateChanged(_, args) {
43 if (args.name === 'mode') {
44 // Delay the update to ensure that `document.activeElement` settled.
45 window.setTimeout(() => {
46 var _a;
47 if (args.newValue === 'command' &&
48 ((_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.closest('.jp-DocumentSearch-overlay'))) {
49 // Do not request updating mode when user switched focus to search overlay.
50 return;
51 }
52 this._updateSelectionMode();
53 this._filtersChanged.emit();
54 }, 0);
55 }
56 }
57 /**
58 * Report whether or not this provider has the ability to search on the given object
59 *
60 * @param domain Widget to test
61 * @returns Search ability
62 */
63 static isApplicable(domain) {
64 // check to see if the CMSearchProvider can search on the
65 // first cell, false indicates another editor is present
66 return domain instanceof NotebookPanel;
67 }
68 /**
69 * Instantiate a search provider for the notebook panel.
70 *
71 * #### Notes
72 * The widget provided is always checked using `isApplicable` before calling
73 * this factory.
74 *
75 * @param widget The widget to search on
76 * @param translator [optional] The translator object
77 *
78 * @returns The search provider on the notebook panel
79 */
80 static createNew(widget, translator) {
81 return new NotebookSearchProvider(widget, translator);
82 }
83 /**
84 * The current index of the selected match.
85 */
86 get currentMatchIndex() {
87 let agg = 0;
88 let found = false;
89 for (let idx = 0; idx < this._searchProviders.length; idx++) {
90 const provider = this._searchProviders[idx];
91 if (this._currentProviderIndex == idx) {
92 const localMatch = provider.currentMatchIndex;
93 if (localMatch === null) {
94 return null;
95 }
96 agg += localMatch;
97 found = true;
98 break;
99 }
100 else {
101 agg += provider.matchesCount;
102 }
103 }
104 return found ? agg : null;
105 }
106 /**
107 * The number of matches.
108 */
109 get matchesCount() {
110 return this._searchProviders.reduce((sum, provider) => (sum += provider.matchesCount), 0);
111 }
112 /**
113 * Set to true if the widget under search is read-only, false
114 * if it is editable. Will be used to determine whether to show
115 * the replace option.
116 */
117 get isReadOnly() {
118 var _a, _b, _c;
119 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;
120 }
121 /**
122 * Support for options adjusting replacement behavior.
123 */
124 get replaceOptionsSupport() {
125 return {
126 preserveCase: true
127 };
128 }
129 /**
130 * Dispose of the resources held by the search provider.
131 *
132 * #### Notes
133 * If the object's `dispose` method is called more than once, all
134 * calls made after the first will be a no-op.
135 *
136 * #### Undefined Behavior
137 * It is undefined behavior to use any functionality of the object
138 * after it has been disposed unless otherwise explicitly noted.
139 */
140 dispose() {
141 var _a;
142 if (this.isDisposed) {
143 return;
144 }
145 this.widget.content.activeCellChanged.disconnect(this._onActiveCellChanged, this);
146 (_a = this.widget.model) === null || _a === void 0 ? void 0 : _a.cells.changed.disconnect(this._onCellsChanged, this);
147 this.widget.content.stateChanged.disconnect(this._onNotebookStateChanged, this);
148 this.widget.content.selectionChanged.disconnect(this._onCellSelectionChanged, this);
149 this._stopObservingLastCell();
150 super.dispose();
151 const index = this.widget.content.activeCellIndex;
152 this.endQuery()
153 .then(() => {
154 if (!this.widget.isDisposed) {
155 this.widget.content.activeCellIndex = index;
156 }
157 })
158 .catch(reason => {
159 console.error(`Fail to end search query in notebook:\n${reason}`);
160 });
161 }
162 /**
163 * Get the filters for the given provider.
164 *
165 * @returns The filters.
166 */
167 getFilters() {
168 const trans = this.translator.load('jupyterlab');
169 return {
170 output: {
171 title: trans.__('Search Cell Outputs'),
172 description: trans.__('Search in the cell outputs.'),
173 default: false,
174 supportReplace: false
175 },
176 selection: {
177 title: this._selectionSearchMode === 'cells'
178 ? trans._n('Search in %1 Selected Cell', 'Search in %1 Selected Cells', this._selectedCells)
179 : trans._n('Search in %1 Selected Line', 'Search in %1 Selected Lines', this._selectedLines),
180 description: trans.__('Search only in the selected cells or text (depending on edit/command mode).'),
181 default: false,
182 supportReplace: true
183 }
184 };
185 }
186 /**
187 * Update the search in selection mode; it should only be called when user
188 * navigates the notebook (enters editing/command mode, changes selection)
189 * but not when the searchbox gets focused (switching the notebook to command
190 * mode) nor when search highlights a match (switching notebook to edit mode).
191 */
192 _updateSelectionMode() {
193 if (this._selectionLock) {
194 return;
195 }
196 this._selectionSearchMode =
197 this._selectedCells === 1 &&
198 this.widget.content.mode === 'edit' &&
199 this._selectedLines !== 0
200 ? 'text'
201 : 'cells';
202 }
203 /**
204 * Get an initial query value if applicable so that it can be entered
205 * into the search box as an initial query
206 *
207 * @returns Initial value used to populate the search box.
208 */
209 getInitialQuery() {
210 const activeCell = this.widget.content.activeCell;
211 const editor = activeCell === null || activeCell === void 0 ? void 0 : activeCell.editor;
212 if (!editor) {
213 return '';
214 }
215 const selection = editor.state.sliceDoc(editor.state.selection.main.from, editor.state.selection.main.to);
216 return selection;
217 }
218 /**
219 * Clear currently highlighted match.
220 */
221 async clearHighlight() {
222 this._selectionLock = true;
223 if (this._currentProviderIndex !== null &&
224 this._currentProviderIndex < this._searchProviders.length) {
225 await this._searchProviders[this._currentProviderIndex].clearHighlight();
226 this._currentProviderIndex = null;
227 }
228 this._selectionLock = false;
229 }
230 /**
231 * Highlight the next match.
232 *
233 * @param loop Whether to loop within the matches list.
234 *
235 * @returns The next match if available.
236 */
237 async highlightNext(loop = true, options) {
238 const match = await this._stepNext(false, loop, options);
239 return match !== null && match !== void 0 ? match : undefined;
240 }
241 /**
242 * Highlight the previous match.
243 *
244 * @param loop Whether to loop within the matches list.
245 *
246 * @returns The previous match if available.
247 */
248 async highlightPrevious(loop = true, options) {
249 const match = await this._stepNext(true, loop, options);
250 return match !== null && match !== void 0 ? match : undefined;
251 }
252 /**
253 * Search for a regular expression with optional filters.
254 *
255 * @param query A regular expression to test for
256 * @param filters Filter parameters to pass to provider
257 *
258 */
259 async startQuery(query, filters) {
260 if (!this.widget) {
261 return;
262 }
263 await this.endQuery();
264 let cells = this.widget.content.widgets;
265 this._query = query;
266 this._filters = {
267 output: false,
268 selection: false,
269 ...(filters !== null && filters !== void 0 ? filters : {})
270 };
271 this._onSelection = this._filters.selection;
272 const currentProviderIndex = this.widget.content.activeCellIndex;
273 // For each cell, create a search provider
274 this._searchProviders = await Promise.all(cells.map(async (cell, index) => {
275 const cellSearchProvider = createCellSearchProvider(cell);
276 await cellSearchProvider.setIsActive(!this._filters.selection ||
277 this.widget.content.isSelectedOrActive(cell));
278 if (this._onSelection &&
279 this._selectionSearchMode === 'text' &&
280 index === currentProviderIndex) {
281 if (this._textSelection) {
282 await cellSearchProvider.setSearchSelection(this._textSelection);
283 }
284 }
285 await cellSearchProvider.startQuery(query, this._filters);
286 return cellSearchProvider;
287 }));
288 this._currentProviderIndex = currentProviderIndex;
289 // We do not want to show the first "current" closest to cursor as depending
290 // on which way the user dragged the selection it would be:
291 // - the first or last match when searching in selection
292 // - the next match when starting search using ctrl + f
293 // `scroll` and `select` are disabled because `startQuery` is also used as
294 // "restartQuery" after each text change and if those were enabled, we would
295 // steal the cursor.
296 await this.highlightNext(true, {
297 from: 'selection-start',
298 scroll: false,
299 select: false
300 });
301 return Promise.resolve();
302 }
303 /**
304 * Stop the search and clear all internal state.
305 */
306 async endQuery() {
307 await Promise.all(this._searchProviders.map(provider => {
308 return provider.endQuery().then(() => {
309 provider.dispose();
310 });
311 }));
312 this._searchProviders.length = 0;
313 this._currentProviderIndex = null;
314 }
315 /**
316 * Replace the currently selected match with the provided text
317 *
318 * @param newText The replacement text.
319 * @param loop Whether to loop within the matches list.
320 *
321 * @returns A promise that resolves with a boolean indicating whether a replace occurred.
322 */
323 async replaceCurrentMatch(newText, loop = true, options) {
324 let replaceOccurred = false;
325 const unrenderMarkdownCell = async (highlightNext = false) => {
326 var _a;
327 // Unrendered markdown cell
328 const activeCell = (_a = this.widget) === null || _a === void 0 ? void 0 : _a.content.activeCell;
329 if ((activeCell === null || activeCell === void 0 ? void 0 : activeCell.model.type) === 'markdown' &&
330 activeCell.rendered) {
331 activeCell.rendered = false;
332 if (highlightNext) {
333 await this.highlightNext(loop);
334 }
335 }
336 };
337 if (this._currentProviderIndex !== null) {
338 await unrenderMarkdownCell();
339 const searchEngine = this._searchProviders[this._currentProviderIndex];
340 replaceOccurred = await searchEngine.replaceCurrentMatch(newText, false, options);
341 if (searchEngine.currentMatchIndex === null) {
342 // switch to next cell
343 await this.highlightNext(loop);
344 }
345 }
346 // TODO: markdown undrendering/highlighting sequence is likely incorrect
347 // Force highlighting the first hit in the unrendered cell
348 await unrenderMarkdownCell(true);
349 return replaceOccurred;
350 }
351 /**
352 * Replace all matches in the notebook with the provided text
353 *
354 * @param newText The replacement text.
355 *
356 * @returns A promise that resolves with a boolean indicating whether a replace occurred.
357 */
358 async replaceAllMatches(newText, options) {
359 const replacementOccurred = await Promise.all(this._searchProviders.map(provider => {
360 return provider.replaceAllMatches(newText, options);
361 }));
362 return replacementOccurred.includes(true);
363 }
364 async validateFilter(name, value) {
365 if (name !== 'output') {
366 // Bail early
367 return value;
368 }
369 // If value is true and some cells have never been rendered, ask confirmation.
370 if (value &&
371 this.widget.content.widgets.some(w => w instanceof CodeCell && w.isPlaceholder())) {
372 const trans = this.translator.load('jupyterlab');
373 const reply = await showDialog({
374 title: trans.__('Confirmation'),
375 body: trans.__('Searching outputs is expensive and requires to first rendered all outputs. Are you sure you want to search in the cell outputs?'),
376 buttons: [
377 Dialog.cancelButton({ label: trans.__('Cancel') }),
378 Dialog.okButton({ label: trans.__('Ok') })
379 ]
380 });
381 if (reply.button.accept) {
382 this.widget.content.widgets.forEach((w, i) => {
383 if (w instanceof CodeCell && w.isPlaceholder()) {
384 this.widget.content.renderCellOutputs(i);
385 }
386 });
387 }
388 else {
389 return false;
390 }
391 }
392 return value;
393 }
394 _addCellProvider(index) {
395 var _a, _b;
396 const cell = this.widget.content.widgets[index];
397 const cellSearchProvider = createCellSearchProvider(cell);
398 ArrayExt.insert(this._searchProviders, index, cellSearchProvider);
399 void cellSearchProvider
400 .setIsActive(!((_b = (_a = this._filters) === null || _a === void 0 ? void 0 : _a.selection) !== null && _b !== void 0 ? _b : false) ||
401 this.widget.content.isSelectedOrActive(cell))
402 .then(() => {
403 void cellSearchProvider.startQuery(this._query, this._filters);
404 });
405 }
406 _removeCellProvider(index) {
407 const provider = ArrayExt.removeAt(this._searchProviders, index);
408 provider === null || provider === void 0 ? void 0 : provider.dispose();
409 }
410 async _onCellsChanged(cells, changes) {
411 switch (changes.type) {
412 case 'add':
413 changes.newValues.forEach((model, index) => {
414 this._addCellProvider(changes.newIndex + index);
415 });
416 break;
417 case 'move':
418 ArrayExt.move(this._searchProviders, changes.oldIndex, changes.newIndex);
419 break;
420 case 'remove':
421 for (let index = 0; index < changes.oldValues.length; index++) {
422 this._removeCellProvider(changes.oldIndex);
423 }
424 break;
425 case 'set':
426 changes.newValues.forEach((model, index) => {
427 this._addCellProvider(changes.newIndex + index);
428 this._removeCellProvider(changes.newIndex + index + 1);
429 });
430 break;
431 }
432 this._stateChanged.emit();
433 }
434 async _stepNext(reverse = false, loop = false, options) {
435 const activateNewMatch = async (match) => {
436 var _a;
437 const shouldScroll = (_a = options === null || options === void 0 ? void 0 : options.scroll) !== null && _a !== void 0 ? _a : true;
438 if (!shouldScroll) {
439 // do not activate the match if scrolling was disabled
440 return;
441 }
442 this._selectionLock = true;
443 if (this.widget.content.activeCellIndex !== this._currentProviderIndex) {
444 this.widget.content.activeCellIndex = this._currentProviderIndex;
445 }
446 if (this.widget.content.activeCellIndex === -1) {
447 console.warn('No active cell (no cells or no model), aborting search');
448 this._selectionLock = false;
449 return;
450 }
451 const activeCell = this.widget.content.activeCell;
452 if (!activeCell.inViewport) {
453 try {
454 await this.widget.content.scrollToItem(this._currentProviderIndex);
455 }
456 catch (error) {
457 // no-op
458 }
459 }
460 // Unhide cell
461 if (activeCell.inputHidden) {
462 activeCell.inputHidden = false;
463 }
464 if (!activeCell.inViewport) {
465 this._selectionLock = false;
466 // It will not be possible the cell is not in the view
467 return;
468 }
469 await activeCell.ready;
470 const editor = activeCell.editor;
471 editor.revealPosition(editor.getPositionAt(match.position));
472 this._selectionLock = false;
473 };
474 if (this._currentProviderIndex === null) {
475 this._currentProviderIndex = this.widget.content.activeCellIndex;
476 }
477 // When going to previous match in cell mode and there is no current we
478 // want to skip the active cell and go to the previous cell; in edit mode
479 // the appropriate behaviour is induced by searching from nearest cursor.
480 if (reverse && this.widget.content.mode === 'command') {
481 const searchEngine = this._searchProviders[this._currentProviderIndex];
482 const currentMatch = searchEngine.getCurrentMatch();
483 if (!currentMatch) {
484 this._currentProviderIndex -= 1;
485 }
486 if (loop) {
487 this._currentProviderIndex =
488 (this._currentProviderIndex + this._searchProviders.length) %
489 this._searchProviders.length;
490 }
491 }
492 const startIndex = this._currentProviderIndex;
493 do {
494 const searchEngine = this._searchProviders[this._currentProviderIndex];
495 const match = reverse
496 ? await searchEngine.highlightPrevious(false, options)
497 : await searchEngine.highlightNext(false, options);
498 if (match) {
499 await activateNewMatch(match);
500 return match;
501 }
502 else {
503 this._currentProviderIndex =
504 this._currentProviderIndex + (reverse ? -1 : 1);
505 if (loop) {
506 this._currentProviderIndex =
507 (this._currentProviderIndex + this._searchProviders.length) %
508 this._searchProviders.length;
509 }
510 }
511 } while (loop
512 ? // We looped on all cells, no hit found
513 this._currentProviderIndex !== startIndex
514 : 0 <= this._currentProviderIndex &&
515 this._currentProviderIndex < this._searchProviders.length);
516 if (loop) {
517 // try the first provider again
518 const searchEngine = this._searchProviders[startIndex];
519 const match = reverse
520 ? await searchEngine.highlightPrevious(false, options)
521 : await searchEngine.highlightNext(false, options);
522 if (match) {
523 await activateNewMatch(match);
524 return match;
525 }
526 }
527 this._currentProviderIndex = null;
528 return null;
529 }
530 async _onActiveCellChanged() {
531 if (this._delayedActiveCellChangeHandler !== null) {
532 // Prevent handler from running twice if active cell is changed twice
533 // within the same task of the event loop.
534 clearTimeout(this._delayedActiveCellChangeHandler);
535 this._delayedActiveCellChangeHandler = null;
536 }
537 if (this.widget.content.activeCellIndex !== this._currentProviderIndex) {
538 // At this time we cannot handle the change of active cell, because
539 // `activeCellChanged` is also emitted in the middle of cell selection
540 // change, and if selection is getting extended, we do not want to clear
541 // highlights just to re-apply them shortly after, which has side effects
542 // impacting the functionality and performance.
543 this._delayedActiveCellChangeHandler = setTimeout(() => {
544 this.delayedActiveCellChangeHandlerReady =
545 this._handleHighlightsAfterActiveCellChange();
546 }, 0);
547 }
548 this._observeActiveCell();
549 }
550 async _handleHighlightsAfterActiveCellChange() {
551 if (this._onSelection) {
552 const previousProviderCell = this._currentProviderIndex !== null &&
553 this._currentProviderIndex < this.widget.content.widgets.length
554 ? this.widget.content.widgets[this._currentProviderIndex]
555 : null;
556 const previousProviderInCurrentSelection = previousProviderCell &&
557 this.widget.content.isSelectedOrActive(previousProviderCell);
558 if (!previousProviderInCurrentSelection) {
559 await this._updateCellSelection();
560 // Clear highlight from previous provider
561 await this.clearHighlight();
562 // If we are searching in all cells, we should not change the active
563 // provider when switching active cell to preserve current match;
564 // if we are searching within selected cells we should update
565 this._currentProviderIndex = this.widget.content.activeCellIndex;
566 }
567 }
568 await this._ensureCurrentMatch();
569 }
570 /**
571 * If there are results but no match is designated as current,
572 * mark a result as current and highlight it.
573 */
574 async _ensureCurrentMatch() {
575 if (this._currentProviderIndex !== null) {
576 const searchEngine = this._searchProviders[this._currentProviderIndex];
577 if (!searchEngine) {
578 // This can happen when `startQuery()` has not finished yet.
579 return;
580 }
581 const currentMatch = searchEngine.getCurrentMatch();
582 if (!currentMatch && this.matchesCount) {
583 // Select a match as current by highlighting next (with looping) from
584 // the selection start, to prevent "current" match from jumping around.
585 await this.highlightNext(true, {
586 from: 'start',
587 scroll: false,
588 select: false
589 });
590 }
591 }
592 }
593 _observeActiveCell() {
594 var _a;
595 const editor = (_a = this.widget.content.activeCell) === null || _a === void 0 ? void 0 : _a.editor;
596 if (!editor) {
597 return;
598 }
599 this._stopObservingLastCell();
600 editor.model.selections.changed.connect(this._setSelectedLines, this);
601 this._editorSelectionsObservable = editor.model.selections;
602 }
603 _stopObservingLastCell() {
604 if (this._editorSelectionsObservable) {
605 this._editorSelectionsObservable.changed.disconnect(this._setSelectedLines, this);
606 }
607 }
608 _setSelectedLines() {
609 var _a;
610 const editor = (_a = this.widget.content.activeCell) === null || _a === void 0 ? void 0 : _a.editor;
611 if (!editor) {
612 return;
613 }
614 const selection = editor.getSelection();
615 const { start, end } = selection;
616 const newLines = end.line === start.line && end.column === start.column
617 ? 0
618 : end.line - start.line + 1;
619 this._textSelection = selection;
620 if (newLines !== this._selectedLines) {
621 this._selectedLines = newLines;
622 this._updateSelectionMode();
623 }
624 this._filtersChanged.emit();
625 }
626 /**
627 * Set whether the engines should search within selection only or full text.
628 */
629 async _setEnginesSelectionSearchMode() {
630 let textMode;
631 if (!this._onSelection) {
632 // When search in selection is off we always search full text
633 textMode = false;
634 }
635 else {
636 // When search in selection is off we either search in full cells
637 // (toggling off isActive flag on search enginges of non-selected cells)
638 // or in selected text of the active cell.
639 textMode = this._selectionSearchMode === 'text';
640 }
641 if (this._selectionLock) {
642 return;
643 }
644 // Clear old selection restrictions or if relevant, set current restrictions for active provider.
645 await Promise.all(this._searchProviders.map((provider, index) => {
646 const isCurrent = this.widget.content.activeCellIndex === index;
647 provider.setProtectSelection(isCurrent && this._onSelection);
648 return provider.setSearchSelection(isCurrent && textMode ? this._textSelection : null);
649 }));
650 }
651 async _onCellSelectionChanged() {
652 if (this._delayedActiveCellChangeHandler !== null) {
653 // Avoid race condition due to `activeCellChanged` and `selectionChanged`
654 // signals firing in short sequence when selection gets extended, with
655 // handling of the former having potential to undo selection set by the latter.
656 clearTimeout(this._delayedActiveCellChangeHandler);
657 this._delayedActiveCellChangeHandler = null;
658 }
659 await this._updateCellSelection();
660 if (this._currentProviderIndex === null) {
661 // For consistency we set the first cell in selection as current provider.
662 const firstSelectedCellIndex = this.widget.content.widgets.findIndex(cell => this.widget.content.isSelectedOrActive(cell));
663 this._currentProviderIndex = firstSelectedCellIndex;
664 }
665 await this._ensureCurrentMatch();
666 }
667 async _updateCellSelection() {
668 const cells = this.widget.content.widgets;
669 let selectedCells = 0;
670 await Promise.all(cells.map(async (cell, index) => {
671 const provider = this._searchProviders[index];
672 const isSelected = this.widget.content.isSelectedOrActive(cell);
673 if (isSelected) {
674 selectedCells += 1;
675 }
676 if (provider && this._onSelection) {
677 await provider.setIsActive(isSelected);
678 }
679 }));
680 if (selectedCells !== this._selectedCells) {
681 this._selectedCells = selectedCells;
682 this._updateSelectionMode();
683 }
684 this._filtersChanged.emit();
685 }
686}
687//# sourceMappingURL=searchprovider.js.map
\No newline at end of file