Skip to content

Commit 1654861

Browse files
krassowskifcollonvalgithub-actions[bot]
authored
Add support for replace preserving case (jupyterlab#13778)
* Add support for replace preserving case * Fix wrong color variable name * Set `line-height` to center replace buttons, remove unused `border-radius` * Fix search test after removing `jp-DocumentSearch-replace-entry` class * Apply suggestions from code review Co-authored-by: Frédéric Collonval <[email protected]> * Update Playwright Snapshots Co-authored-by: Frédéric Collonval <[email protected]> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 40afbd9 commit 1654861

File tree

12 files changed

+269
-61
lines changed

12 files changed

+269
-61
lines changed
Loading
Loading

galata/test/jupyterlab/search.test.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ test('Search with a text', async ({ page }) => {
2727
});
2828
}, searchText);
2929

30-
expect(
31-
await page.locator('input.jp-DocumentSearch-input').inputValue()
32-
).toEqual(searchText);
30+
expect(await page.locator('[placeholder="Find"]').inputValue()).toEqual(
31+
searchText
32+
);
3333
});
3434

3535
test('Search with a text and replacement', async ({ page }) => {
@@ -54,10 +54,10 @@ test('Search with a text and replacement', async ({ page }) => {
5454
[searchText, replaceText]
5555
);
5656

57-
expect(
58-
await page.locator('input.jp-DocumentSearch-input').inputValue()
59-
).toEqual(searchText);
60-
expect(
61-
await page.locator('input.jp-DocumentSearch-replace-entry').inputValue()
62-
).toEqual(replaceText);
57+
expect(await page.locator('[placeholder="Find"]').inputValue()).toEqual(
58+
searchText
59+
);
60+
expect(await page.locator('[placeholder="Replace"]').inputValue()).toEqual(
61+
replaceText
62+
);
6363
});

packages/cells/src/searchprovider.ts

+18-4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
GenericSearchProvider,
1212
IBaseSearchProvider,
1313
IFilters,
14+
IReplaceOptions,
1415
ISearchMatch,
1516
TextSearchEngine
1617
} from '@jupyterlab/documentsearch';
@@ -218,7 +219,11 @@ export class CellSearchProvider implements IBaseSearchProvider {
218219
* @param newText The replacement text.
219220
* @returns Whether a replace occurred.
220221
*/
221-
replaceCurrentMatch(newText: string): Promise<boolean> {
222+
replaceCurrentMatch(
223+
newText: string,
224+
loop?: boolean,
225+
options?: IReplaceOptions
226+
): Promise<boolean> {
222227
if (!this.isActive) {
223228
return Promise.resolve(false);
224229
}
@@ -244,10 +249,13 @@ export class CellSearchProvider implements IBaseSearchProvider {
244249
this.currentIndex = null;
245250
// Store the current position to highlight properly the next search hit
246251
this._lastReplacementPosition = editor.getCursorPosition();
252+
const insertText = options?.preserveCase
253+
? GenericSearchProvider.preserveCase(match.text, newText)
254+
: newText;
247255
this.cell.model.sharedModel.updateSource(
248256
match!.position,
249257
match!.position + match!.text.length,
250-
newText
258+
insertText
251259
);
252260
occurred = true;
253261
}
@@ -262,7 +270,10 @@ export class CellSearchProvider implements IBaseSearchProvider {
262270
* @param newText The replacement text.
263271
* @returns Whether a replace occurred.
264272
*/
265-
replaceAllMatches(newText: string): Promise<boolean> {
273+
replaceAllMatches(
274+
newText: string,
275+
options?: IReplaceOptions
276+
): Promise<boolean> {
266277
if (!this.isActive) {
267278
return Promise.resolve(false);
268279
}
@@ -273,7 +284,10 @@ export class CellSearchProvider implements IBaseSearchProvider {
273284
const finalSrc = this.cmHandler.matches.reduce((agg, match) => {
274285
const start = match.position as number;
275286
const end = start + match.text.length;
276-
const newStep = `${agg}${src.slice(lastEnd, start)}${newText}`;
287+
const insertText = options?.preserveCase
288+
? GenericSearchProvider.preserveCase(match.text, newText)
289+
: newText;
290+
const newStep = `${agg}${src.slice(lastEnd, start)}${insertText}`;
277291
lastEnd = end;
278292
return newStep;
279293
}, '');

packages/codemirror/src/searchprovider.ts

+25-4
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
*/
3232

3333
import {
34+
GenericSearchProvider,
3435
IBaseSearchProvider,
36+
IReplaceOptions,
3537
ISearchMatch,
3638
TextSearchEngine
3739
} from '@jupyterlab/documentsearch';
@@ -272,7 +274,11 @@ export class CodeMirrorSearchProvider implements IBaseSearchProvider {
272274
*
273275
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
274276
*/
275-
async replaceCurrentMatch(newText: string, loop?: boolean): Promise<boolean> {
277+
async replaceCurrentMatch(
278+
newText: string,
279+
loop?: boolean,
280+
options?: IReplaceOptions
281+
): Promise<boolean> {
276282
// If the current selection exactly matches the current match,
277283
// replace it. Otherwise, just select the next match after the cursor.
278284
let replaceOccurred = false;
@@ -288,8 +294,14 @@ export class CodeMirrorSearchProvider implements IBaseSearchProvider {
288294
}
289295
const value = cursor.value;
290296
replaceOccurred = true;
297+
const insertText = options?.preserveCase
298+
? GenericSearchProvider.preserveCase(
299+
this.editor.doc.sliceString(cursor.value.from, cursor.value.to),
300+
newText
301+
)
302+
: newText;
291303
this.editor.editor.dispatch({
292-
changes: { from: value.from, to: value.to, insert: newText }
304+
changes: { from: value.from, to: value.to, insert: insertText }
293305
});
294306
}
295307
await this.highlightNext();
@@ -303,7 +315,10 @@ export class CodeMirrorSearchProvider implements IBaseSearchProvider {
303315
*
304316
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
305317
*/
306-
async replaceAllMatches(newText: string): Promise<boolean> {
318+
async replaceAllMatches(
319+
newText: string,
320+
options?: IReplaceOptions
321+
): Promise<boolean> {
307322
let replaceOccurred = false;
308323
return new Promise((resolve, _) => {
309324
let cursor = new RegExpCursor(this.editor.doc, this._query.source, {
@@ -312,10 +327,16 @@ export class CodeMirrorSearchProvider implements IBaseSearchProvider {
312327
let changeSpec: ChangeSpec[] = [];
313328
while (!cursor.done) {
314329
replaceOccurred = true;
330+
const insertText = options?.preserveCase
331+
? GenericSearchProvider.preserveCase(
332+
this.editor.doc.sliceString(cursor.value.from, cursor.value.to),
333+
newText
334+
)
335+
: newText;
315336
changeSpec.push({
316337
from: cursor.value.from,
317338
to: cursor.value.to,
318-
insert: newText
339+
insert: insertText
319340
});
320341
cursor = cursor.next();
321342
}

packages/documentsearch/src/searchmodel.ts

+34-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { VDomModel } from '@jupyterlab/ui-components';
55
import { IObservableDisposable } from '@lumino/disposable';
66
import { Debouncer } from '@lumino/polling';
77
import { ISignal, Signal } from '@lumino/signaling';
8-
import { IFilter, IFilters, ISearchProvider } from './tokens';
8+
import {
9+
IFilter,
10+
IFilters,
11+
IReplaceOptionsSupport,
12+
ISearchProvider
13+
} from './tokens';
914

1015
/**
1116
* Search in a document model.
@@ -98,13 +103,34 @@ export class SearchDocumentModel
98103
return this.searchProvider.isReadOnly;
99104
}
100105

106+
/**
107+
* Replace options support.
108+
*/
109+
get replaceOptionsSupport(): IReplaceOptionsSupport | undefined {
110+
return this.searchProvider.replaceOptionsSupport;
111+
}
112+
101113
/**
102114
* Parsing regular expression error message.
103115
*/
104116
get parsingError(): string {
105117
return this._parsingError;
106118
}
107119

120+
/**
121+
* Whether to preserve case when replacing.
122+
*/
123+
get preserveCase(): boolean {
124+
return this._preserveCase;
125+
}
126+
set preserveCase(v: boolean) {
127+
if (this._preserveCase !== v) {
128+
this._preserveCase = v;
129+
this.stateChanged.emit();
130+
this.refresh();
131+
}
132+
}
133+
108134
/**
109135
* Replacement expression
110136
*/
@@ -229,7 +255,9 @@ export class SearchDocumentModel
229255
* Replace all matches.
230256
*/
231257
async replaceAllMatches(): Promise<void> {
232-
await this.searchProvider.replaceAllMatches(this._replaceText);
258+
await this.searchProvider.replaceAllMatches(this._replaceText, {
259+
preserveCase: this.preserveCase
260+
});
233261
// Emit state change as the index needs to be updated
234262
this.stateChanged.emit();
235263
}
@@ -238,7 +266,9 @@ export class SearchDocumentModel
238266
* Replace the current match.
239267
*/
240268
async replaceCurrentMatch(): Promise<void> {
241-
await this.searchProvider.replaceCurrentMatch(this._replaceText);
269+
await this.searchProvider.replaceCurrentMatch(this._replaceText, true, {
270+
preserveCase: this.preserveCase
271+
});
242272
// Emit state change as the index needs to be updated
243273
this.stateChanged.emit();
244274
}
@@ -298,6 +328,7 @@ export class SearchDocumentModel
298328
private _caseSensitive = false;
299329
private _disposed = new Signal<this, void>(this);
300330
private _parsingError = '';
331+
private _preserveCase = false;
301332
private _filters: IFilters = {};
302333
private _replaceText: string;
303334
private _searchDebouncer: Debouncer;

packages/documentsearch/src/searchprovider.ts

+39-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33

44
import { ISignal, Signal } from '@lumino/signaling';
55
import { Widget } from '@lumino/widgets';
6-
import { IFilter, IFilters, ISearchMatch, ISearchProvider } from './tokens';
6+
import {
7+
IFilter,
8+
IFilters,
9+
IReplaceOptions,
10+
ISearchMatch,
11+
ISearchProvider
12+
} from './tokens';
713

814
/**
915
* Abstract class implementing the search provider interface.
@@ -137,7 +143,11 @@ export abstract class SearchProvider<T extends Widget = Widget>
137143
*
138144
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
139145
*/
140-
abstract replaceCurrentMatch(newText: string): Promise<boolean>;
146+
abstract replaceCurrentMatch(
147+
newText: string,
148+
loop?: boolean,
149+
options?: IReplaceOptions
150+
): Promise<boolean>;
141151

142152
/**
143153
* Replace all matches in the widget with the provided text
@@ -146,9 +156,35 @@ export abstract class SearchProvider<T extends Widget = Widget>
146156
*
147157
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
148158
*/
149-
abstract replaceAllMatches(newText: string): Promise<boolean>;
159+
abstract replaceAllMatches(
160+
newText: string,
161+
options?: IReplaceOptions
162+
): Promise<boolean>;
163+
164+
/**
165+
* Utility for copying the letter case from old to new text.
166+
*/
167+
static preserveCase(oldText: string, newText: string): string {
168+
if (oldText.toUpperCase() === oldText) {
169+
return newText.toUpperCase();
170+
}
171+
if (oldText.toLowerCase() === oldText) {
172+
return newText.toLowerCase();
173+
}
174+
if (toSentenceCase(oldText) === oldText) {
175+
return toSentenceCase(newText);
176+
}
177+
return newText;
178+
}
150179

151180
// Needs to be protected so subclass can emit the signal too.
152181
protected _stateChanged: Signal<this, void>;
153182
private _disposed: boolean;
154183
}
184+
185+
/**
186+
* Capitalise first letter of provided word.
187+
*/
188+
function toSentenceCase([first = '', ...suffix]: string): string {
189+
return first.toUpperCase() + '' + suffix.join('').toLowerCase();
190+
}

0 commit comments

Comments
 (0)