UNPKG

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