@jupyterlab/codemirror 4.0.0-alpha.1 → 4.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,685 @@
1
+ // Copyright (c) Jupyter Development Team.
2
+ // Distributed under the terms of the Modified BSD License.
3
+ import { TextSearchEngine } from '@jupyterlab/documentsearch';
4
+ import { JSONExt } from '@lumino/coreutils';
5
+ import { Signal } from '@lumino/signaling';
6
+ import * as CodeMirror from 'codemirror';
7
+ /**
8
+ * CodeMirror search provider for file editor
9
+ */
10
+ export class CodeMirrorSearchProvider {
11
+ constructor() {
12
+ /**
13
+ * Set to true if the widget under search is read-only, false
14
+ * if it is editable. Will be used to determine whether to show
15
+ * the replace option.
16
+ */
17
+ this.isReadOnly = false;
18
+ this._matchState = {};
19
+ this._changed = new Signal(this);
20
+ this._disposed = false;
21
+ }
22
+ /**
23
+ * Whether the search provider is disposed or not.
24
+ */
25
+ get isDisposed() {
26
+ return this._disposed;
27
+ }
28
+ /**
29
+ * Dispose the search provider
30
+ */
31
+ dispose() {
32
+ if (this._disposed) {
33
+ return;
34
+ }
35
+ this._disposed = true;
36
+ Signal.clearData(this);
37
+ }
38
+ /**
39
+ * Initialize the search using a CodeMirrorEditor object.
40
+ *
41
+ * @param query the search regular expression
42
+ */
43
+ async startQuery(query) {
44
+ if (!this.editor) {
45
+ return Promise.resolve();
46
+ }
47
+ return this._startQuery(query);
48
+ }
49
+ /**
50
+ * Refresh the search highlight overlay
51
+ */
52
+ refreshOverlay() {
53
+ this._refreshOverlay();
54
+ }
55
+ async _startQuery(query, refreshOverlay = true) {
56
+ // no point in removing overlay in the middle of the search
57
+ await this.endQuery(false);
58
+ this._query = query;
59
+ CodeMirror.on(this.editor.doc, 'change', this._onDocChanged.bind(this));
60
+ if (refreshOverlay) {
61
+ this._refreshOverlay();
62
+ }
63
+ await this._setInitialMatches(query);
64
+ const matches = this._parseMatchesFromState();
65
+ if (matches.length === 0) {
66
+ return Promise.resolve();
67
+ }
68
+ const cursorMatch = this._findNext(false);
69
+ const match = cursorMatch &&
70
+ this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
71
+ this._currentMatch = match;
72
+ return Promise.resolve();
73
+ }
74
+ /**
75
+ * Clears state of a search provider to prepare for startQuery to be called
76
+ * in order to start a new query or refresh an existing one.
77
+ *
78
+ * @param removeOverlay Whether to remove the search highlight overlay or not.
79
+ */
80
+ async endQuery(removeOverlay = true) {
81
+ this._matchState = {};
82
+ this._currentMatch = null;
83
+ if (removeOverlay) {
84
+ this.editor.removeOverlay(this._overlay);
85
+ }
86
+ const from = this.editor.getCursor('from');
87
+ const to = this.editor.getCursor('to');
88
+ // Setting a reverse selection to allow search-as-you-type to maintain the
89
+ // current selected match. See comment in _findNext for more details.
90
+ if (from !== to) {
91
+ this.editor.setSelection({
92
+ start: this._toEditorPos(to),
93
+ end: this._toEditorPos(from)
94
+ });
95
+ }
96
+ CodeMirror.off(this.editor.doc, 'change', this._onDocChanged.bind(this));
97
+ }
98
+ /**
99
+ * Clear currently highlighted match.
100
+ */
101
+ clearHighlight() {
102
+ this._currentMatch = null;
103
+ const cursor = this.editor.getCursorPosition();
104
+ // Reset cursor position to remove any selection
105
+ this.editor.setCursorPosition(cursor);
106
+ return Promise.resolve();
107
+ }
108
+ /**
109
+ * Move the current match indicator to the next match.
110
+ *
111
+ * @param loop Whether to loop within the matches list.
112
+ *
113
+ * @returns A promise that resolves once the action has completed.
114
+ */
115
+ async highlightNext(loop) {
116
+ const cursorMatch = this._findNext(false);
117
+ if (!cursorMatch) {
118
+ return;
119
+ }
120
+ const match = this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
121
+ this._currentMatch = match;
122
+ return match;
123
+ }
124
+ /**
125
+ * Move the current match indicator to the previous match.
126
+ *
127
+ * @param loop Whether to loop within the matches list.
128
+ *
129
+ * @returns A promise that resolves once the action has completed.
130
+ */
131
+ async highlightPrevious(loop) {
132
+ const cursorMatch = this._findNext(true);
133
+ if (!cursorMatch) {
134
+ return;
135
+ }
136
+ const match = this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
137
+ this._currentMatch = match;
138
+ return match;
139
+ }
140
+ /**
141
+ * Replace the currently selected match with the provided text
142
+ *
143
+ * @param newText The replacement text
144
+ * @param loop Whether to loop within the matches list.
145
+ *
146
+ * @returns A promise that resolves with a boolean indicating whether a replace occurred.
147
+ */
148
+ async replaceCurrentMatch(newText, loop) {
149
+ // If the current selection exactly matches the current match,
150
+ // replace it. Otherwise, just select the next match after the cursor.
151
+ let replaceOccurred = false;
152
+ if (this._currentMatchIsSelected()) {
153
+ const cursor = this.editor.getSearchCursor(this._query, this.editor.getCursor('from'), !this._query.ignoreCase);
154
+ if (!cursor.findNext()) {
155
+ return replaceOccurred;
156
+ }
157
+ replaceOccurred = true;
158
+ cursor.replace(newText);
159
+ }
160
+ await this.highlightNext();
161
+ return replaceOccurred;
162
+ }
163
+ /**
164
+ * Replace all matches in the notebook with the provided text
165
+ *
166
+ * @param newText The replacement text
167
+ *
168
+ * @returns A promise that resolves with a boolean indicating whether a replace occurred.
169
+ */
170
+ async replaceAllMatches(newText) {
171
+ let replaceOccurred = false;
172
+ return new Promise((resolve, _) => {
173
+ this.editor.operation(() => {
174
+ const cursor = this.editor.getSearchCursor(this._query, undefined, !this._query.ignoreCase);
175
+ while (cursor.findNext()) {
176
+ replaceOccurred = true;
177
+ cursor.replace(newText);
178
+ }
179
+ this._matchState = {};
180
+ this._currentMatch = null;
181
+ resolve(replaceOccurred);
182
+ });
183
+ });
184
+ }
185
+ /**
186
+ * The list of matches
187
+ */
188
+ get matches() {
189
+ return this._parseMatchesFromState();
190
+ }
191
+ /**
192
+ * The number of matches.
193
+ */
194
+ get matchesCount() {
195
+ let size = 0;
196
+ for (const line in this._matchState) {
197
+ size += Object.keys(this._matchState[line]).length;
198
+ }
199
+ return size;
200
+ }
201
+ /**
202
+ * The current match
203
+ */
204
+ get currentMatch() {
205
+ return this._currentMatch;
206
+ }
207
+ /**
208
+ * Signal indicating that something in the search has changed, so the UI should update
209
+ */
210
+ get stateChanged() {
211
+ return this._changed;
212
+ }
213
+ /**
214
+ * The current index of the selected match.
215
+ */
216
+ get currentMatchIndex() {
217
+ if (!this._currentMatch) {
218
+ return null;
219
+ }
220
+ // TODO make it more efficient
221
+ return this.matches.indexOf(this._currentMatch);
222
+ }
223
+ _onDocChanged(_, changeObj) {
224
+ var _a, _b;
225
+ // If we get newlines added/removed, the line numbers across the
226
+ // match state are all shifted, so here we need to recalculate it
227
+ if (changeObj.text.length > 1 || ((_b = (_a = changeObj.removed) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 1) {
228
+ this._setInitialMatches(this._query)
229
+ .then(() => {
230
+ this._changed.emit(undefined);
231
+ })
232
+ .catch(reason => {
233
+ console.error(`Fail to reapply search on CodeMirror document change:\n${reason}`);
234
+ });
235
+ }
236
+ }
237
+ _refreshOverlay() {
238
+ this.editor.operation(() => {
239
+ // clear search first
240
+ this.editor.removeOverlay(this._overlay);
241
+ this._overlay = this._getSearchOverlay();
242
+ this.editor.addOverlay(this._overlay);
243
+ this._changed.emit(undefined);
244
+ });
245
+ }
246
+ /**
247
+ * Do a full search on the entire document.
248
+ *
249
+ * This manually constructs the initial match state across the whole
250
+ * document. This must be done manually because the codemirror overlay
251
+ * is lazy-loaded, so it will only tokenize lines that are in or near
252
+ * the viewport. This is sufficient for efficiently maintaining the
253
+ * state when changes are made to the document, as changes occur in or
254
+ * near the viewport, but to scan the whole document, a manual search
255
+ * across the entire content is required.
256
+ *
257
+ * @param query The search term
258
+ */
259
+ async _setInitialMatches(query) {
260
+ this._matchState = {};
261
+ const content = this.editor.doc.getValue();
262
+ const matches = await TextSearchEngine.search(query, content);
263
+ matches.forEach(match => {
264
+ const { line, ch } = this.editor.doc.posFromIndex(match.position);
265
+ if (!this._matchState[line]) {
266
+ this._matchState[line] = {};
267
+ }
268
+ this._matchState[line][ch] = match;
269
+ });
270
+ }
271
+ _getSearchOverlay() {
272
+ return {
273
+ /**
274
+ * Token function is called when a line needs to be processed -
275
+ * when the overlay is initially created, it's called on all lines;
276
+ * when a line is modified and needs to be re-evaluated, it's called
277
+ * on just that line.
278
+ *
279
+ * This implementation of the token function both constructs/maintains
280
+ * the overlay and keeps track of the match state as the document is
281
+ * updated while a search is active.
282
+ */
283
+ token: (stream) => {
284
+ const currentPos = stream.pos;
285
+ this._query.lastIndex = currentPos;
286
+ const lineText = stream.string;
287
+ const match = this._query.exec(lineText);
288
+ const line = stream.lineOracle.line;
289
+ // If starting at position 0, the tokenization of this line has just started.
290
+ // Blow away everything on this line in the state so it can be updated.
291
+ if (stream.start === currentPos &&
292
+ currentPos === 0 &&
293
+ !!this._matchState[line]) {
294
+ this._matchState[line] = {};
295
+ }
296
+ if (match && match.index === currentPos) {
297
+ // found match, add it to state
298
+ const matchLength = match[0].length;
299
+ const matchObj = {
300
+ text: lineText.slice(currentPos, currentPos + matchLength),
301
+ position: this.editor.doc.indexFromPos({
302
+ line,
303
+ ch: currentPos
304
+ })
305
+ };
306
+ if (!this._matchState[line]) {
307
+ this._matchState[line] = {};
308
+ }
309
+ this._matchState[line][currentPos] = matchObj;
310
+ // move the stream along and return searching style for the token
311
+ stream.pos += matchLength || 1;
312
+ // if the last thing on the line was a match, make sure we still
313
+ // emit the changed signal so the display can pick up the updates
314
+ if (stream.eol()) {
315
+ this._changed.emit(undefined);
316
+ }
317
+ return 'searching';
318
+ }
319
+ else if (match) {
320
+ // there's a match in the stream, advance the stream to its position
321
+ stream.pos = match.index;
322
+ }
323
+ else {
324
+ // no matches, consume the rest of the stream
325
+ this._changed.emit(undefined);
326
+ stream.skipToEnd();
327
+ }
328
+ }
329
+ };
330
+ }
331
+ _findNext(reverse) {
332
+ return this.editor.operation(() => {
333
+ const caseSensitive = this._query.ignoreCase;
334
+ // In order to support search-as-you-type, we needed a way to allow the first
335
+ // match to be selected when a search is started, but prevent the selected
336
+ // search to move for each new keypress. To do this, when a search is ended,
337
+ // the cursor is reversed, putting the head at the 'from' position. When a new
338
+ // search is started, the cursor we want is at the 'from' position, so that the same
339
+ // match is selected when the next key is entered (if it is still a match).
340
+ //
341
+ // When toggling through a search normally, the cursor is always set in the forward
342
+ // direction, so head is always at the 'to' position. That way, if reverse = false,
343
+ // the search proceeds from the 'to' position during normal toggling. If reverse = true,
344
+ // the search always proceeds from the 'anchor' position, which is at the 'from'.
345
+ const cursorToGet = reverse ? 'anchor' : 'head';
346
+ const lastPosition = this.editor.getCursor(cursorToGet);
347
+ let cursor = this.editor.getSearchCursor(this._query, lastPosition, !caseSensitive);
348
+ if (!cursor.find(reverse)) {
349
+ // if we do want to loop, try searching from the bottom/top
350
+ const startOrEnd = reverse
351
+ ? CodeMirror.Pos(this.editor.lastLine())
352
+ : CodeMirror.Pos(this.editor.firstLine(), 0);
353
+ cursor = this.editor.getSearchCursor(this._query, startOrEnd, !caseSensitive);
354
+ if (!cursor.find(reverse)) {
355
+ return null;
356
+ }
357
+ }
358
+ const fromPos = cursor.from();
359
+ const toPos = cursor.to();
360
+ const selRange = {
361
+ start: {
362
+ line: fromPos.line,
363
+ column: fromPos.ch
364
+ },
365
+ end: {
366
+ line: toPos.line,
367
+ column: toPos.ch
368
+ }
369
+ };
370
+ this.editor.setSelection(selRange);
371
+ this.editor.scrollIntoView({
372
+ from: fromPos,
373
+ to: toPos
374
+ }, 100);
375
+ return {
376
+ from: fromPos,
377
+ to: toPos
378
+ };
379
+ });
380
+ }
381
+ _parseMatchesFromState() {
382
+ // Flatten state map
383
+ const matches = new Array();
384
+ for (const lineKey in this._matchState) {
385
+ const lineMatches = this._matchState[lineKey];
386
+ for (const posKey in lineMatches) {
387
+ matches.push(lineMatches[posKey]);
388
+ }
389
+ }
390
+ return matches;
391
+ }
392
+ _toEditorPos(posIn) {
393
+ return {
394
+ line: posIn.line,
395
+ column: posIn.ch
396
+ };
397
+ }
398
+ _currentMatchIsSelected() {
399
+ if (!this._currentMatch) {
400
+ return false;
401
+ }
402
+ const currentSelection = this.editor.getSelection();
403
+ const currentSelectionLength = currentSelection.end.column - currentSelection.start.column;
404
+ const selectionIsOneLine = currentSelection.start.line === currentSelection.end.line;
405
+ return (selectionIsOneLine &&
406
+ this._currentMatch.text.length === currentSelectionLength &&
407
+ this._currentMatch.position ===
408
+ this.editor.doc.indexFromPos({
409
+ line: currentSelection.start.line,
410
+ ch: currentSelection.start.column
411
+ }));
412
+ }
413
+ }
414
+ /**
415
+ * Helper class to highlight texts in a code mirror editor.
416
+ *
417
+ * Highlighted texts (aka `matches`) must be provided through
418
+ * the `matches` attributes.
419
+ */
420
+ export class CodeMirrorSearchHighlighter {
421
+ /**
422
+ * Constructor
423
+ *
424
+ * @param editor The CodeMirror editor
425
+ */
426
+ constructor(editor) {
427
+ this._cm = editor;
428
+ this._matches = new Array();
429
+ this._currentIndex = null;
430
+ }
431
+ /**
432
+ * The current index of the selected match.
433
+ */
434
+ get currentIndex() {
435
+ return this._currentIndex;
436
+ }
437
+ /**
438
+ * The list of matches
439
+ */
440
+ get matches() {
441
+ return this._matches;
442
+ }
443
+ set matches(v) {
444
+ if (!JSONExt.deepEqual(this._matches, v)) {
445
+ this._matches = v;
446
+ }
447
+ this.refresh();
448
+ }
449
+ /**
450
+ * Clear all highlighted matches
451
+ */
452
+ clearHighlight() {
453
+ this._currentIndex = null;
454
+ this._highlightCurrentMatch();
455
+ }
456
+ /**
457
+ * Refresh the highlight matches overlay
458
+ */
459
+ refresh() {
460
+ this._refreshOverlay();
461
+ }
462
+ /**
463
+ * Clear the highlighted matches.
464
+ */
465
+ endQuery() {
466
+ this._currentIndex = null;
467
+ this._matches = [];
468
+ this._cm.removeOverlay('jp-searching');
469
+ this._overlay = null;
470
+ const from = this._cm.getCursor('from');
471
+ const to = this._cm.getCursor('to');
472
+ // Setting a reverse selection to allow search-as-you-type to maintain the
473
+ // current selected match. See comment in _findNext for more details.
474
+ if (from !== to) {
475
+ this._cm.setSelection({
476
+ start: this._toEditorPos(to),
477
+ end: this._toEditorPos(from)
478
+ });
479
+ }
480
+ return Promise.resolve();
481
+ }
482
+ /**
483
+ * Highlight the next match
484
+ *
485
+ * @returns The next match if available
486
+ */
487
+ highlightNext() {
488
+ this._currentIndex = this._findNext(false);
489
+ this._highlightCurrentMatch();
490
+ return Promise.resolve(this._currentIndex !== null
491
+ ? this._matches[this._currentIndex]
492
+ : undefined);
493
+ }
494
+ /**
495
+ * Highlight the previous match
496
+ *
497
+ * @returns The previous match if available
498
+ */
499
+ highlightPrevious() {
500
+ this._currentIndex = this._findNext(true);
501
+ this._highlightCurrentMatch();
502
+ return Promise.resolve(this._currentIndex !== null
503
+ ? this._matches[this._currentIndex]
504
+ : undefined);
505
+ }
506
+ _highlightCurrentMatch() {
507
+ // Highlight the current index
508
+ if (this._currentIndex !== null) {
509
+ const match = this.matches[this._currentIndex];
510
+ this._cm.operation(() => {
511
+ const start = this._cm.doc.posFromIndex(match.position);
512
+ const from = {
513
+ line: start.line,
514
+ column: start.ch
515
+ };
516
+ const to = {
517
+ // Matches is on the same line
518
+ line: start.line,
519
+ column: start.ch + match.text.length
520
+ };
521
+ // No need to scroll into view this is the default behavior
522
+ this._cm.setSelection({
523
+ start: from,
524
+ end: to
525
+ });
526
+ });
527
+ }
528
+ else {
529
+ // Set cursor to remove any selection
530
+ this._cm.setCursorPosition({ line: 0, column: 0 });
531
+ }
532
+ }
533
+ _refreshOverlay() {
534
+ // clear search first
535
+ this._cm.removeOverlay('jp-searching');
536
+ this._overlay = this._getSearchOverlay();
537
+ this._cm.addOverlay(this._overlay);
538
+ }
539
+ _getSearchOverlay() {
540
+ const token = (stream) => {
541
+ const position = this._cm.doc.indexFromPos({
542
+ line: stream.lineOracle.line,
543
+ ch: stream.pos
544
+ });
545
+ let found = this._matches.length > 0
546
+ ? Utils.findNext(this._matches, position, 0, // lastMatchIndex,
547
+ this._matches.length - 1)
548
+ : null;
549
+ if (found !== null) {
550
+ // lastMatchIndex = found;
551
+ const match = this._matches[found];
552
+ if (match.position > position + stream.string.length) {
553
+ // next match not in this stream, consume the rest of the stream
554
+ stream.skipToEnd();
555
+ return null;
556
+ }
557
+ if (position === match.position) {
558
+ // move the stream along and return searching style for the token
559
+ stream.pos += match.text.length || 1;
560
+ return 'searching';
561
+ }
562
+ else {
563
+ // Move to the next match
564
+ stream.pos += match.position - position;
565
+ }
566
+ }
567
+ else {
568
+ // no matches, consume the rest of the stream
569
+ stream.skipToEnd();
570
+ }
571
+ return null;
572
+ };
573
+ return {
574
+ name: 'jp-searching',
575
+ /**
576
+ * Token function is called when a line needs to be processed -
577
+ * when the overlay is initially created, it's called on all lines;
578
+ * when a line is modified and needs to be re-evaluated, it's called
579
+ * on just that line.
580
+ *
581
+ * This implementation of the token function both constructs/maintains
582
+ * the overlay and keeps track of the match state as the document is
583
+ * updated while a search is active.
584
+ */
585
+ token: token.bind(this)
586
+ };
587
+ }
588
+ _findNext(reverse) {
589
+ if (this._matches.length === 0) {
590
+ // No-op
591
+ return null;
592
+ }
593
+ // In order to support search-as-you-type, we needed a way to allow the first
594
+ // match to be selected when a search is started, but prevent the selected
595
+ // search to move for each new keypress. To do this, when a search is ended,
596
+ // the cursor is reversed, putting the head at the 'from' position. When a new
597
+ // search is started, the cursor we want is at the 'from' position, so that the same
598
+ // match is selected when the next key is entered (if it is still a match).
599
+ //
600
+ // When toggling through a search normally, the cursor is always set in the forward
601
+ // direction, so head is always at the 'to' position. That way, if reverse = false,
602
+ // the search proceeds from the 'to' position during normal toggling. If reverse = true,
603
+ // the search always proceeds from the 'anchor' position, which is at the 'from'.
604
+ const cursorToGet = reverse ? 'anchor' : 'head';
605
+ let lastPosition = this._cm.getCursor(cursorToGet);
606
+ if (lastPosition.line === 0 &&
607
+ lastPosition.ch === 0 &&
608
+ reverse &&
609
+ this.currentIndex === null) {
610
+ // The default position is (0, 0) but we want to start from the end in that case
611
+ lastPosition = {
612
+ // Go to virtual next line so position got clamp to end
613
+ line: this._cm.lineCount,
614
+ ch: 0
615
+ };
616
+ }
617
+ const position = this._cm.doc.indexFromPos(lastPosition);
618
+ let found = Utils.findNext(this._matches, position, 0, this._matches.length - 1);
619
+ if (found === null) {
620
+ // Don't loop
621
+ return reverse ? this._matches.length - 1 : null;
622
+ }
623
+ if (reverse) {
624
+ found -= 1;
625
+ if (found < 0) {
626
+ // Don't loop
627
+ return null;
628
+ }
629
+ }
630
+ return found;
631
+ }
632
+ _toEditorPos(posIn) {
633
+ return {
634
+ line: posIn.line,
635
+ column: posIn.ch
636
+ };
637
+ }
638
+ }
639
+ /**
640
+ * Helpers namespace
641
+ */
642
+ var Utils;
643
+ (function (Utils) {
644
+ /**
645
+ * Find the closest match at `position` just after it.
646
+ *
647
+ * #### Notes
648
+ * Search is done using a binary search algorithm
649
+ *
650
+ * @param matches List of matches
651
+ * @param position Searched position
652
+ * @param lowerBound Lower range index
653
+ * @param higherBound High range index
654
+ * @returns The next match or null if none exists
655
+ */
656
+ function findNext(matches, position, lowerBound = 0, higherBound = Infinity) {
657
+ higherBound = Math.min(matches.length - 1, higherBound);
658
+ while (lowerBound <= higherBound) {
659
+ let middle = Math.floor(0.5 * (lowerBound + higherBound));
660
+ const currentPosition = matches[middle].position;
661
+ if (currentPosition < position) {
662
+ lowerBound = middle + 1;
663
+ if (lowerBound < matches.length &&
664
+ matches[lowerBound].position > position) {
665
+ return lowerBound;
666
+ }
667
+ }
668
+ else if (currentPosition > position) {
669
+ higherBound = middle - 1;
670
+ if (higherBound > 0 && matches[higherBound].position < position) {
671
+ return middle;
672
+ }
673
+ }
674
+ else {
675
+ return middle;
676
+ }
677
+ }
678
+ // Next could be the first item
679
+ const first = lowerBound > 0 ? lowerBound - 1 : 0;
680
+ const match = matches[first];
681
+ return match.position >= position ? first : null;
682
+ }
683
+ Utils.findNext = findNext;
684
+ })(Utils || (Utils = {}));
685
+ //# sourceMappingURL=searchprovider.js.map