Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/contrib/rename/browser/renameWidget.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as dom from '../../../../base/browser/dom.js';
7
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
8
import * as aria from '../../../../base/browser/ui/aria/aria.js';
9
import { getBaseLayerHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate2.js';
10
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
11
import { IListRenderer, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
12
import { List } from '../../../../base/browser/ui/list/listWidget.js';
13
import * as arrays from '../../../../base/common/arrays.js';
14
import { DeferredPromise, raceCancellation } from '../../../../base/common/async.js';
15
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
16
import { Codicon } from '../../../../base/common/codicons.js';
17
import { Emitter } from '../../../../base/common/event.js';
18
import { KeyCode } from '../../../../base/common/keyCodes.js';
19
import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
20
import { StopWatch } from '../../../../base/common/stopwatch.js';
21
import { assertType, isDefined } from '../../../../base/common/types.js';
22
import './renameWidget.css';
23
import * as domFontInfo from '../../../browser/config/domFontInfo.js';
24
import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from '../../../browser/editorBrowser.js';
25
import { EditorOption } from '../../../common/config/editorOptions.js';
26
import { FontInfo } from '../../../common/config/fontInfo.js';
27
import { IDimension } from '../../../common/core/2d/dimension.js';
28
import { Position } from '../../../common/core/position.js';
29
import { IRange, Range } from '../../../common/core/range.js';
30
import { ScrollType } from '../../../common/editorCommon.js';
31
import { NewSymbolName, NewSymbolNameTag, NewSymbolNameTriggerKind, ProviderResult } from '../../../common/languages.js';
32
import * as nls from '../../../../nls.js';
33
import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
34
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
35
import { ILogService } from '../../../../platform/log/common/log.js';
36
import { getListStyles } from '../../../../platform/theme/browser/defaultStyles.js';
37
import {
38
editorWidgetBackground,
39
inputBackground,
40
inputBorder,
41
inputForeground,
42
quickInputListFocusBackground,
43
quickInputListFocusForeground,
44
widgetBorder,
45
widgetShadow
46
} from '../../../../platform/theme/common/colorRegistry.js';
47
import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js';
48
49
/** for debugging */
50
const _sticky = false
51
// || Boolean("true") // done "weirdly" so that a lint warning prevents you from pushing this
52
;
53
54
55
export const CONTEXT_RENAME_INPUT_VISIBLE = new RawContextKey<boolean>('renameInputVisible', false, nls.localize('renameInputVisible', "Whether the rename input widget is visible"));
56
export const CONTEXT_RENAME_INPUT_FOCUSED = new RawContextKey<boolean>('renameInputFocused', false, nls.localize('renameInputFocused', "Whether the rename input widget is focused"));
57
58
/**
59
* "Source" of the new name:
60
* - 'inputField' - user entered the new name
61
* - 'renameSuggestion' - user picked from rename suggestions
62
* - 'userEditedRenameSuggestion' - user _likely_ edited a rename suggestion ("likely" because when input started being edited, a rename suggestion had focus)
63
*/
64
export type NewNameSource =
65
| { k: 'inputField' }
66
| { k: 'renameSuggestion' }
67
| { k: 'userEditedRenameSuggestion' };
68
69
/**
70
* Various statistics regarding rename input field
71
*/
72
export type RenameWidgetStats = {
73
nRenameSuggestions: number;
74
source: NewNameSource;
75
timeBeforeFirstInputFieldEdit: number | undefined;
76
nRenameSuggestionsInvocations: number;
77
hadAutomaticRenameSuggestionsInvocation: boolean;
78
};
79
80
export type RenameWidgetResult = {
81
/**
82
* The new name to be used
83
*/
84
newName: string;
85
wantsPreview?: boolean;
86
stats: RenameWidgetStats;
87
};
88
89
interface IRenameWidget {
90
/**
91
* @returns a `boolean` standing for `shouldFocusEditor`, if user didn't pick a new name, or a {@link RenameWidgetResult}
92
*/
93
getInput(
94
where: IRange,
95
currentName: string,
96
supportPreview: boolean,
97
requestRenameSuggestions: (triggerKind: NewSymbolNameTriggerKind, cts: CancellationToken) => ProviderResult<NewSymbolName[]>[],
98
cts: CancellationTokenSource
99
): Promise<RenameWidgetResult | boolean>;
100
101
acceptInput(wantsPreview: boolean): void;
102
cancelInput(focusEditor: boolean, caller: string): void;
103
104
focusNextRenameSuggestion(): void;
105
focusPreviousRenameSuggestion(): void;
106
}
107
108
export class RenameWidget implements IRenameWidget, IContentWidget, IDisposable {
109
110
// implement IContentWidget
111
readonly allowEditorOverflow: boolean = true;
112
113
// UI state
114
115
private _domNode?: HTMLElement;
116
private _inputWithButton: InputWithButton;
117
private _renameCandidateListView?: RenameCandidateListView;
118
private _label?: HTMLDivElement;
119
120
private _nPxAvailableAbove?: number;
121
private _nPxAvailableBelow?: number;
122
123
// Model state
124
125
private _position?: Position;
126
private _currentName?: string;
127
/** Is true if input field got changes when a rename candidate was focused; otherwise, false */
128
private _isEditingRenameCandidate: boolean;
129
130
private readonly _candidates: Set<string>;
131
132
private _visible?: boolean;
133
134
/** must be reset at session start */
135
private _beforeFirstInputFieldEditSW: StopWatch;
136
137
/**
138
* Milliseconds before user edits the input field for the first time
139
* @remarks must be set once per session
140
*/
141
private _timeBeforeFirstInputFieldEdit: number | undefined;
142
143
private _nRenameSuggestionsInvocations: number;
144
145
private _hadAutomaticRenameSuggestionsInvocation: boolean;
146
147
private _renameCandidateProvidersCts: CancellationTokenSource | undefined;
148
private _renameCts: CancellationTokenSource | undefined;
149
150
private readonly _visibleContextKey: IContextKey<boolean>;
151
private readonly _disposables = new DisposableStore();
152
153
constructor(
154
private readonly _editor: ICodeEditor,
155
private readonly _acceptKeybindings: [string, string],
156
@IThemeService private readonly _themeService: IThemeService,
157
@IKeybindingService private readonly _keybindingService: IKeybindingService,
158
@IContextKeyService contextKeyService: IContextKeyService,
159
@ILogService private readonly _logService: ILogService,
160
) {
161
this._visibleContextKey = CONTEXT_RENAME_INPUT_VISIBLE.bindTo(contextKeyService);
162
163
this._isEditingRenameCandidate = false;
164
165
this._nRenameSuggestionsInvocations = 0;
166
167
this._hadAutomaticRenameSuggestionsInvocation = false;
168
169
this._candidates = new Set();
170
171
this._beforeFirstInputFieldEditSW = new StopWatch();
172
173
this._inputWithButton = new InputWithButton();
174
this._disposables.add(this._inputWithButton);
175
176
this._editor.addContentWidget(this);
177
178
this._disposables.add(this._editor.onDidChangeConfiguration(e => {
179
if (e.hasChanged(EditorOption.fontInfo)) {
180
this._updateFont();
181
}
182
}));
183
184
this._disposables.add(_themeService.onDidColorThemeChange(this._updateStyles, this));
185
}
186
187
dispose(): void {
188
this._disposables.dispose();
189
this._editor.removeContentWidget(this);
190
}
191
192
getId(): string {
193
return '__renameInputWidget';
194
}
195
196
getDomNode(): HTMLElement {
197
if (!this._domNode) {
198
this._domNode = document.createElement('div');
199
this._domNode.className = 'monaco-editor rename-box';
200
201
this._domNode.appendChild(this._inputWithButton.domNode);
202
203
this._renameCandidateListView = this._disposables.add(
204
new RenameCandidateListView(this._domNode, {
205
fontInfo: this._editor.getOption(EditorOption.fontInfo),
206
onFocusChange: (newSymbolName: string) => {
207
this._inputWithButton.input.value = newSymbolName;
208
this._isEditingRenameCandidate = false; // @ulugbekna: reset
209
},
210
onSelectionChange: () => {
211
this._isEditingRenameCandidate = false; // @ulugbekna: because user picked a rename suggestion
212
this.acceptInput(false); // we don't allow preview with mouse click for now
213
}
214
})
215
);
216
217
this._disposables.add(
218
this._inputWithButton.onDidInputChange(() => {
219
if (this._renameCandidateListView?.focusedCandidate !== undefined) {
220
this._isEditingRenameCandidate = true;
221
}
222
this._timeBeforeFirstInputFieldEdit ??= this._beforeFirstInputFieldEditSW.elapsed();
223
if (this._renameCandidateProvidersCts?.token.isCancellationRequested === false) {
224
this._renameCandidateProvidersCts.cancel();
225
}
226
this._renameCandidateListView?.clearFocus();
227
})
228
);
229
230
this._label = document.createElement('div');
231
this._label.className = 'rename-label';
232
this._domNode.appendChild(this._label);
233
234
this._updateFont();
235
this._updateStyles(this._themeService.getColorTheme());
236
}
237
return this._domNode;
238
}
239
240
private _updateStyles(theme: IColorTheme): void {
241
if (!this._domNode) {
242
return;
243
}
244
245
const widgetShadowColor = theme.getColor(widgetShadow);
246
const widgetBorderColor = theme.getColor(widgetBorder);
247
this._domNode.style.backgroundColor = String(theme.getColor(editorWidgetBackground) ?? '');
248
this._domNode.style.boxShadow = widgetShadowColor ? ` 0 0 8px 2px ${widgetShadowColor}` : '';
249
this._domNode.style.border = widgetBorderColor ? `1px solid ${widgetBorderColor}` : '';
250
this._domNode.style.color = String(theme.getColor(inputForeground) ?? '');
251
252
const border = theme.getColor(inputBorder);
253
254
this._inputWithButton.domNode.style.backgroundColor = String(theme.getColor(inputBackground) ?? '');
255
this._inputWithButton.input.style.backgroundColor = String(theme.getColor(inputBackground) ?? '');
256
this._inputWithButton.domNode.style.borderWidth = border ? '1px' : '0px';
257
this._inputWithButton.domNode.style.borderStyle = border ? 'solid' : 'none';
258
this._inputWithButton.domNode.style.borderColor = border?.toString() ?? 'none';
259
}
260
261
private _updateFont(): void {
262
if (this._domNode === undefined) {
263
return;
264
}
265
assertType(this._label !== undefined, 'RenameWidget#_updateFont: _label must not be undefined given _domNode is defined');
266
267
this._editor.applyFontInfo(this._inputWithButton.input);
268
269
const fontInfo = this._editor.getOption(EditorOption.fontInfo);
270
this._label.style.fontSize = `${this._computeLabelFontSize(fontInfo.fontSize)}px`;
271
}
272
273
private _computeLabelFontSize(editorFontSize: number) {
274
return editorFontSize * 0.8;
275
}
276
277
getPosition(): IContentWidgetPosition | null {
278
if (!this._visible) {
279
return null;
280
}
281
282
if (!this._editor.hasModel() || // @ulugbekna: shouldn't happen
283
!this._editor.getDomNode() // @ulugbekna: can happen during tests based on suggestWidget's similar predicate check
284
) {
285
return null;
286
}
287
288
const bodyBox = dom.getClientArea(this.getDomNode().ownerDocument.body);
289
const editorBox = dom.getDomNodePagePosition(this._editor.getDomNode());
290
291
const cursorBoxTop = this._getTopForPosition();
292
293
this._nPxAvailableAbove = cursorBoxTop + editorBox.top;
294
this._nPxAvailableBelow = bodyBox.height - this._nPxAvailableAbove;
295
296
const lineHeight = this._editor.getOption(EditorOption.lineHeight);
297
const { totalHeight: candidateViewHeight } = RenameCandidateView.getLayoutInfo({ lineHeight });
298
299
const positionPreference = this._nPxAvailableBelow > candidateViewHeight * 6 /* approximate # of candidates to fit in (inclusive of rename input box & rename label) */
300
? [ContentWidgetPositionPreference.BELOW, ContentWidgetPositionPreference.ABOVE]
301
: [ContentWidgetPositionPreference.ABOVE, ContentWidgetPositionPreference.BELOW];
302
303
return {
304
position: this._position!,
305
preference: positionPreference,
306
};
307
}
308
309
beforeRender(): IDimension | null {
310
const [accept, preview] = this._acceptKeybindings;
311
this._label!.innerText = nls.localize({ key: 'label', comment: ['placeholders are keybindings, e.g "F2 to Rename, Shift+F2 to Preview"'] }, "{0} to Rename, {1} to Preview", this._keybindingService.lookupKeybinding(accept)?.getLabel(), this._keybindingService.lookupKeybinding(preview)?.getLabel());
312
313
this._domNode!.style.minWidth = `200px`; // to prevent from widening when candidates come in
314
315
return null;
316
}
317
318
afterRender(position: ContentWidgetPositionPreference | null): void {
319
// FIXME@ulugbekna: commenting trace log out until we start unmounting the widget from editor properly - https://github.com/microsoft/vscode/issues/226975
320
// this._trace('invoking afterRender, position: ', position ? 'not null' : 'null');
321
if (position === null) {
322
// cancel rename when input widget isn't rendered anymore
323
this.cancelInput(true, 'afterRender (because position is null)');
324
return;
325
}
326
327
if (!this._editor.hasModel() || // shouldn't happen
328
!this._editor.getDomNode() // can happen during tests based on suggestWidget's similar predicate check
329
) {
330
return;
331
}
332
333
assertType(this._renameCandidateListView);
334
assertType(this._nPxAvailableAbove !== undefined);
335
assertType(this._nPxAvailableBelow !== undefined);
336
337
const inputBoxHeight = dom.getTotalHeight(this._inputWithButton.domNode);
338
339
const labelHeight = dom.getTotalHeight(this._label!);
340
341
let totalHeightAvailable: number;
342
if (position === ContentWidgetPositionPreference.BELOW) {
343
totalHeightAvailable = this._nPxAvailableBelow;
344
} else {
345
totalHeightAvailable = this._nPxAvailableAbove;
346
}
347
348
this._renameCandidateListView!.layout({
349
height: totalHeightAvailable - labelHeight - inputBoxHeight,
350
width: dom.getTotalWidth(this._inputWithButton.domNode),
351
});
352
}
353
354
355
private _currentAcceptInput?: (wantsPreview: boolean) => void;
356
private _currentCancelInput?: (focusEditor: boolean) => void;
357
private _requestRenameCandidatesOnce?: (triggerKind: NewSymbolNameTriggerKind, cts: CancellationToken) => ProviderResult<NewSymbolName[]>[];
358
359
acceptInput(wantsPreview: boolean): void {
360
this._trace(`invoking acceptInput`);
361
this._currentAcceptInput?.(wantsPreview);
362
}
363
364
cancelInput(focusEditor: boolean, caller: string): void {
365
// this._trace(`invoking cancelInput, caller: ${caller}, _currentCancelInput: ${this._currentAcceptInput ? 'not undefined' : 'undefined'}`);
366
this._currentCancelInput?.(focusEditor);
367
}
368
369
focusNextRenameSuggestion() {
370
if (!this._renameCandidateListView?.focusNext()) {
371
this._inputWithButton.input.value = this._currentName!;
372
}
373
}
374
375
focusPreviousRenameSuggestion() { // TODO@ulugbekna: this and focusNext should set the original name if no candidate is focused
376
if (!this._renameCandidateListView?.focusPrevious()) {
377
this._inputWithButton.input.value = this._currentName!;
378
}
379
}
380
381
/**
382
* @param requestRenameCandidates is `undefined` when there are no rename suggestion providers
383
*/
384
getInput(
385
where: IRange,
386
currentName: string,
387
supportPreview: boolean,
388
requestRenameCandidates: undefined | ((triggerKind: NewSymbolNameTriggerKind, cts: CancellationToken) => ProviderResult<NewSymbolName[]>[]),
389
cts: CancellationTokenSource
390
): Promise<RenameWidgetResult | boolean> {
391
392
const { start: selectionStart, end: selectionEnd } = this._getSelection(where, currentName);
393
394
this._renameCts = cts;
395
396
const disposeOnDone = new DisposableStore();
397
398
this._nRenameSuggestionsInvocations = 0;
399
400
this._hadAutomaticRenameSuggestionsInvocation = false;
401
402
if (requestRenameCandidates === undefined) {
403
this._inputWithButton.button.style.display = 'none';
404
} else {
405
this._inputWithButton.button.style.display = 'flex';
406
407
this._requestRenameCandidatesOnce = requestRenameCandidates;
408
409
this._requestRenameCandidates(currentName, false);
410
411
disposeOnDone.add(dom.addDisposableListener(
412
this._inputWithButton.button,
413
'click',
414
() => this._requestRenameCandidates(currentName, true)
415
));
416
disposeOnDone.add(dom.addDisposableListener(
417
this._inputWithButton.button,
418
dom.EventType.KEY_DOWN,
419
(e) => {
420
const keyEvent = new StandardKeyboardEvent(e);
421
422
if (keyEvent.equals(KeyCode.Enter) || keyEvent.equals(KeyCode.Space)) {
423
keyEvent.stopPropagation();
424
keyEvent.preventDefault();
425
this._requestRenameCandidates(currentName, true);
426
}
427
}
428
));
429
}
430
431
this._isEditingRenameCandidate = false;
432
433
this._domNode!.classList.toggle('preview', supportPreview);
434
435
this._position = new Position(where.startLineNumber, where.startColumn);
436
this._currentName = currentName;
437
438
this._inputWithButton.input.value = currentName;
439
this._inputWithButton.input.setAttribute('selectionStart', selectionStart.toString());
440
this._inputWithButton.input.setAttribute('selectionEnd', selectionEnd.toString());
441
this._inputWithButton.input.size = Math.max((where.endColumn - where.startColumn) * 1.1, 20); // determines width
442
443
this._beforeFirstInputFieldEditSW.reset();
444
445
446
disposeOnDone.add(toDisposable(() => {
447
this._renameCts = undefined;
448
cts.dispose(true);
449
})); // @ulugbekna: this may result in `this.cancelInput` being called twice, but it should be safe since we set it to undefined after 1st call
450
disposeOnDone.add(toDisposable(() => {
451
if (this._renameCandidateProvidersCts !== undefined) {
452
this._renameCandidateProvidersCts.dispose(true);
453
this._renameCandidateProvidersCts = undefined;
454
}
455
}));
456
457
disposeOnDone.add(toDisposable(() => this._candidates.clear()));
458
459
const inputResult = new DeferredPromise<RenameWidgetResult | boolean>();
460
461
inputResult.p.finally(() => {
462
disposeOnDone.dispose();
463
this._hide();
464
});
465
466
this._currentCancelInput = (focusEditor) => {
467
this._trace('invoking _currentCancelInput');
468
this._currentAcceptInput = undefined;
469
this._currentCancelInput = undefined;
470
// fixme session cleanup
471
this._renameCandidateListView?.clearCandidates();
472
inputResult.complete(focusEditor);
473
return true;
474
};
475
476
this._currentAcceptInput = (wantsPreview) => {
477
this._trace('invoking _currentAcceptInput');
478
assertType(this._renameCandidateListView !== undefined);
479
480
const nRenameSuggestions = this._renameCandidateListView.nCandidates;
481
482
let newName: string;
483
let source: NewNameSource;
484
const focusedCandidate = this._renameCandidateListView.focusedCandidate;
485
if (focusedCandidate !== undefined) {
486
this._trace('using new name from renameSuggestion');
487
newName = focusedCandidate;
488
source = { k: 'renameSuggestion' };
489
} else {
490
this._trace('using new name from inputField');
491
newName = this._inputWithButton.input.value;
492
source = this._isEditingRenameCandidate ? { k: 'userEditedRenameSuggestion' } : { k: 'inputField' };
493
}
494
495
if (newName === currentName || newName.trim().length === 0 /* is just whitespace */) {
496
this.cancelInput(true, '_currentAcceptInput (because newName === value || newName.trim().length === 0)');
497
return;
498
}
499
500
this._currentAcceptInput = undefined;
501
this._currentCancelInput = undefined;
502
this._renameCandidateListView.clearCandidates();
503
// fixme session cleanup
504
505
inputResult.complete({
506
newName,
507
wantsPreview: supportPreview && wantsPreview,
508
stats: {
509
source,
510
nRenameSuggestions,
511
timeBeforeFirstInputFieldEdit: this._timeBeforeFirstInputFieldEdit,
512
nRenameSuggestionsInvocations: this._nRenameSuggestionsInvocations,
513
hadAutomaticRenameSuggestionsInvocation: this._hadAutomaticRenameSuggestionsInvocation,
514
}
515
});
516
};
517
518
disposeOnDone.add(cts.token.onCancellationRequested(() => this.cancelInput(true, 'cts.token.onCancellationRequested')));
519
if (!_sticky) {
520
disposeOnDone.add(this._editor.onDidBlurEditorWidget(() => this.cancelInput(!this._domNode?.ownerDocument.hasFocus(), 'editor.onDidBlurEditorWidget')));
521
}
522
523
this._show();
524
525
return inputResult.p;
526
}
527
528
private _requestRenameCandidates(currentName: string, isManuallyTriggered: boolean) {
529
if (this._requestRenameCandidatesOnce === undefined) {
530
return;
531
}
532
if (this._renameCandidateProvidersCts !== undefined) {
533
this._renameCandidateProvidersCts.dispose(true);
534
}
535
536
assertType(this._renameCts);
537
538
if (this._inputWithButton.buttonState !== 'stop') {
539
540
this._renameCandidateProvidersCts = new CancellationTokenSource();
541
542
const triggerKind = isManuallyTriggered ? NewSymbolNameTriggerKind.Invoke : NewSymbolNameTriggerKind.Automatic;
543
const candidates = this._requestRenameCandidatesOnce(triggerKind, this._renameCandidateProvidersCts.token);
544
545
if (candidates.length === 0) {
546
this._inputWithButton.setSparkleButton();
547
return;
548
}
549
550
if (!isManuallyTriggered) {
551
this._hadAutomaticRenameSuggestionsInvocation = true;
552
}
553
554
this._nRenameSuggestionsInvocations += 1;
555
556
this._inputWithButton.setStopButton();
557
558
this._updateRenameCandidates(candidates, currentName, this._renameCts.token);
559
}
560
}
561
562
/**
563
* This allows selecting only part of the symbol name in the input field based on the selection in the editor
564
*/
565
private _getSelection(where: IRange, currentName: string): { start: number; end: number } {
566
assertType(this._editor.hasModel());
567
568
const selection = this._editor.getSelection();
569
let start = 0;
570
let end = currentName.length;
571
572
if (!Range.isEmpty(selection) && !Range.spansMultipleLines(selection) && Range.containsRange(where, selection)) {
573
start = Math.max(0, selection.startColumn - where.startColumn);
574
end = Math.min(where.endColumn, selection.endColumn) - where.startColumn;
575
}
576
577
return { start, end };
578
}
579
580
private _show(): void {
581
this._trace('invoking _show');
582
this._editor.revealLineInCenterIfOutsideViewport(this._position!.lineNumber, ScrollType.Smooth);
583
this._visible = true;
584
this._visibleContextKey.set(true);
585
this._editor.layoutContentWidget(this);
586
587
// TODO@ulugbekna: could this be simply run in `afterRender`?
588
setTimeout(() => {
589
this._inputWithButton.input.focus();
590
this._inputWithButton.input.setSelectionRange(
591
parseInt(this._inputWithButton.input.getAttribute('selectionStart')!),
592
parseInt(this._inputWithButton.input.getAttribute('selectionEnd')!)
593
);
594
}, 100);
595
}
596
597
private async _updateRenameCandidates(candidates: ProviderResult<NewSymbolName[]>[], currentName: string, token: CancellationToken) {
598
const trace = (...args: any[]) => this._trace('_updateRenameCandidates', ...args);
599
600
trace('start');
601
const namesListResults = await raceCancellation(Promise.allSettled(candidates), token);
602
603
this._inputWithButton.setSparkleButton();
604
605
if (namesListResults === undefined) {
606
trace('returning early - received updateRenameCandidates results - undefined');
607
return;
608
}
609
610
const newNames = namesListResults.flatMap(namesListResult =>
611
namesListResult.status === 'fulfilled' && isDefined(namesListResult.value)
612
? namesListResult.value
613
: []
614
);
615
trace(`received updateRenameCandidates results - total (unfiltered) ${newNames.length} candidates.`);
616
617
// deduplicate and filter out the current value
618
619
const distinctNames = arrays.distinct(newNames, v => v.newSymbolName);
620
trace(`distinct candidates - ${distinctNames.length} candidates.`);
621
622
const validDistinctNames = distinctNames.filter(({ newSymbolName }) => newSymbolName.trim().length > 0 && newSymbolName !== this._inputWithButton.input.value && newSymbolName !== currentName && !this._candidates.has(newSymbolName));
623
trace(`valid distinct candidates - ${newNames.length} candidates.`);
624
625
validDistinctNames.forEach(n => this._candidates.add(n.newSymbolName));
626
627
if (validDistinctNames.length < 1) {
628
trace('returning early - no valid distinct candidates');
629
return;
630
}
631
632
// show the candidates
633
trace('setting candidates');
634
this._renameCandidateListView!.setCandidates(validDistinctNames);
635
636
// ask editor to re-layout given that the widget is now of a different size after rendering rename candidates
637
trace('asking editor to re-layout');
638
this._editor.layoutContentWidget(this);
639
}
640
641
private _hide(): void {
642
this._trace('invoked _hide');
643
this._visible = false;
644
this._visibleContextKey.reset();
645
this._editor.layoutContentWidget(this);
646
}
647
648
private _getTopForPosition(): number {
649
const visibleRanges = this._editor.getVisibleRanges();
650
let firstLineInViewport: number;
651
if (visibleRanges.length > 0) {
652
firstLineInViewport = visibleRanges[0].startLineNumber;
653
} else {
654
this._logService.warn('RenameWidget#_getTopForPosition: this should not happen - visibleRanges is empty');
655
firstLineInViewport = Math.max(1, this._position!.lineNumber - 5); // @ulugbekna: fallback to current line minus 5
656
}
657
return this._editor.getTopForLineNumber(this._position!.lineNumber) - this._editor.getTopForLineNumber(firstLineInViewport);
658
}
659
660
private _trace(...args: unknown[]) {
661
this._logService.trace('RenameWidget', ...args);
662
}
663
}
664
665
class RenameCandidateListView {
666
667
/** Parent node of the list widget; needed to control # of list elements visible */
668
private readonly _listContainer: HTMLDivElement;
669
private readonly _listWidget: List<NewSymbolName>;
670
671
private _lineHeight: number;
672
private _availableHeight: number;
673
private _minimumWidth: number;
674
private _typicalHalfwidthCharacterWidth: number;
675
676
private readonly _disposables: DisposableStore;
677
678
// FIXME@ulugbekna: rewrite using event emitters
679
constructor(parent: HTMLElement, opts: { fontInfo: FontInfo; onFocusChange: (newSymbolName: string) => void; onSelectionChange: () => void }) {
680
681
this._disposables = new DisposableStore();
682
683
this._availableHeight = 0;
684
this._minimumWidth = 0;
685
686
this._lineHeight = opts.fontInfo.lineHeight;
687
this._typicalHalfwidthCharacterWidth = opts.fontInfo.typicalHalfwidthCharacterWidth;
688
689
this._listContainer = document.createElement('div');
690
this._listContainer.className = 'rename-box rename-candidate-list-container';
691
parent.appendChild(this._listContainer);
692
693
this._listWidget = RenameCandidateListView._createListWidget(this._listContainer, this._candidateViewHeight, opts.fontInfo);
694
695
this._disposables.add(this._listWidget.onDidChangeFocus(
696
e => {
697
if (e.elements.length === 1) {
698
opts.onFocusChange(e.elements[0].newSymbolName);
699
}
700
},
701
this._disposables
702
));
703
704
this._disposables.add(this._listWidget.onDidChangeSelection(
705
e => {
706
if (e.elements.length === 1) {
707
opts.onSelectionChange();
708
}
709
},
710
this._disposables
711
));
712
713
this._disposables.add(
714
this._listWidget.onDidBlur(e => { // @ulugbekna: because list widget otherwise remembers last focused element and returns it as focused element
715
this._listWidget.setFocus([]);
716
})
717
);
718
719
this._listWidget.style(getListStyles({
720
listInactiveFocusForeground: quickInputListFocusForeground,
721
listInactiveFocusBackground: quickInputListFocusBackground,
722
}));
723
}
724
725
dispose() {
726
this._listWidget.dispose();
727
this._disposables.dispose();
728
}
729
730
// height - max height allowed by parent element
731
public layout({ height, width }: { height: number; width: number }): void {
732
this._availableHeight = height;
733
this._minimumWidth = width;
734
}
735
736
public setCandidates(candidates: NewSymbolName[]): void {
737
738
// insert candidates into list widget
739
this._listWidget.splice(0, 0, candidates);
740
741
// adjust list widget layout
742
const height = this._pickListHeight(this._listWidget.length);
743
const width = this._pickListWidth(candidates);
744
745
this._listWidget.layout(height, width);
746
747
// adjust list container layout
748
this._listContainer.style.height = `${height}px`;
749
this._listContainer.style.width = `${width}px`;
750
751
aria.status(nls.localize('renameSuggestionsReceivedAria', "Received {0} rename suggestions", candidates.length));
752
}
753
754
public clearCandidates(): void {
755
this._listContainer.style.height = '0px';
756
this._listContainer.style.width = '0px';
757
this._listWidget.splice(0, this._listWidget.length, []);
758
}
759
760
public get nCandidates() {
761
return this._listWidget.length;
762
}
763
764
public get focusedCandidate(): string | undefined {
765
if (this._listWidget.length === 0) {
766
return;
767
}
768
const selectedElement = this._listWidget.getSelectedElements()[0];
769
if (selectedElement !== undefined) {
770
return selectedElement.newSymbolName;
771
}
772
const focusedElement = this._listWidget.getFocusedElements()[0];
773
if (focusedElement !== undefined) {
774
return focusedElement.newSymbolName;
775
}
776
return;
777
}
778
779
public focusNext(): boolean {
780
if (this._listWidget.length === 0) {
781
return false;
782
}
783
const focusedIxs = this._listWidget.getFocus();
784
if (focusedIxs.length === 0) {
785
this._listWidget.focusFirst();
786
this._listWidget.reveal(0);
787
return true;
788
} else {
789
if (focusedIxs[0] === this._listWidget.length - 1) {
790
this._listWidget.setFocus([]);
791
this._listWidget.reveal(0); // @ulugbekna: without this, it seems like focused element is obstructed
792
return false;
793
} else {
794
this._listWidget.focusNext();
795
const focused = this._listWidget.getFocus()[0];
796
this._listWidget.reveal(focused);
797
return true;
798
}
799
}
800
}
801
802
/**
803
* @returns true if focus is moved to previous element
804
*/
805
public focusPrevious(): boolean {
806
if (this._listWidget.length === 0) {
807
return false;
808
}
809
const focusedIxs = this._listWidget.getFocus();
810
if (focusedIxs.length === 0) {
811
this._listWidget.focusLast();
812
const focused = this._listWidget.getFocus()[0];
813
this._listWidget.reveal(focused);
814
return true;
815
} else {
816
if (focusedIxs[0] === 0) {
817
this._listWidget.setFocus([]);
818
return false;
819
} else {
820
this._listWidget.focusPrevious();
821
const focused = this._listWidget.getFocus()[0];
822
this._listWidget.reveal(focused);
823
return true;
824
}
825
}
826
}
827
828
public clearFocus(): void {
829
this._listWidget.setFocus([]);
830
}
831
832
private get _candidateViewHeight(): number {
833
const { totalHeight } = RenameCandidateView.getLayoutInfo({ lineHeight: this._lineHeight });
834
return totalHeight;
835
}
836
837
private _pickListHeight(nCandidates: number) {
838
const heightToFitAllCandidates = this._candidateViewHeight * nCandidates;
839
const MAX_N_CANDIDATES = 7; // @ulugbekna: max # of candidates we want to show at once
840
const height = Math.min(heightToFitAllCandidates, this._availableHeight, this._candidateViewHeight * MAX_N_CANDIDATES);
841
return height;
842
}
843
844
private _pickListWidth(candidates: NewSymbolName[]): number {
845
const longestCandidateWidth = Math.ceil(Math.max(...candidates.map(c => c.newSymbolName.length)) * this._typicalHalfwidthCharacterWidth);
846
const width = Math.max(
847
this._minimumWidth,
848
4 /* padding */ + 16 /* sparkle icon */ + 5 /* margin-left */ + longestCandidateWidth + 10 /* (possibly visible) scrollbar width */ // TODO@ulugbekna: approximate calc - clean this up
849
);
850
return width;
851
}
852
853
private static _createListWidget(container: HTMLElement, candidateViewHeight: number, fontInfo: FontInfo) {
854
const virtualDelegate = new class implements IListVirtualDelegate<NewSymbolName> {
855
getTemplateId(element: NewSymbolName): string {
856
return 'candidate';
857
}
858
859
getHeight(element: NewSymbolName): number {
860
return candidateViewHeight;
861
}
862
};
863
864
const renderer = new class implements IListRenderer<NewSymbolName, RenameCandidateView> {
865
readonly templateId = 'candidate';
866
867
renderTemplate(container: HTMLElement): RenameCandidateView {
868
return new RenameCandidateView(container, fontInfo);
869
}
870
871
renderElement(candidate: NewSymbolName, index: number, templateData: RenameCandidateView): void {
872
templateData.populate(candidate);
873
}
874
875
disposeTemplate(templateData: RenameCandidateView): void {
876
templateData.dispose();
877
}
878
};
879
880
return new List(
881
'NewSymbolNameCandidates',
882
container,
883
virtualDelegate,
884
[renderer],
885
{
886
keyboardSupport: false, // @ulugbekna: because we handle keyboard events through proper commands & keybinding service, see `rename.ts`
887
mouseSupport: true,
888
multipleSelectionSupport: false,
889
}
890
);
891
}
892
}
893
894
class InputWithButton implements IDisposable {
895
896
private _buttonState: 'sparkle' | 'stop' | undefined;
897
898
private _domNode: HTMLDivElement | undefined;
899
private _inputNode: HTMLInputElement | undefined;
900
private _buttonNode: HTMLElement | undefined;
901
private _buttonHoverContent: string = '';
902
private _buttonGenHoverText: string | undefined;
903
private _buttonCancelHoverText: string | undefined;
904
private _sparkleIcon: HTMLElement | undefined;
905
private _stopIcon: HTMLElement | undefined;
906
907
private readonly _onDidInputChange = new Emitter<void>();
908
public readonly onDidInputChange = this._onDidInputChange.event;
909
910
private readonly _disposables = new DisposableStore();
911
912
get domNode() {
913
if (!this._domNode) {
914
915
this._domNode = document.createElement('div');
916
this._domNode.className = 'rename-input-with-button';
917
this._domNode.style.display = 'flex';
918
this._domNode.style.flexDirection = 'row';
919
this._domNode.style.alignItems = 'center';
920
921
this._inputNode = document.createElement('input');
922
this._inputNode.className = 'rename-input';
923
this._inputNode.type = 'text';
924
this._inputNode.style.border = 'none';
925
this._inputNode.setAttribute('aria-label', nls.localize('renameAriaLabel', "Rename input. Type new name and press Enter to commit."));
926
927
this._domNode.appendChild(this._inputNode);
928
929
this._buttonNode = document.createElement('div');
930
this._buttonNode.className = 'rename-suggestions-button';
931
this._buttonNode.setAttribute('tabindex', '0');
932
933
this._buttonGenHoverText = nls.localize('generateRenameSuggestionsButton', "Generate new name suggestions");
934
this._buttonCancelHoverText = nls.localize('cancelRenameSuggestionsButton', "Cancel");
935
this._buttonHoverContent = this._buttonGenHoverText;
936
this._disposables.add(getBaseLayerHoverDelegate().setupDelayedHover(this._buttonNode, () => ({
937
content: this._buttonHoverContent,
938
appearance: {
939
showPointer: true,
940
compact: true,
941
}
942
})));
943
944
this._domNode.appendChild(this._buttonNode);
945
946
// notify if selection changes to cancel request to rename-suggestion providers
947
948
this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.INPUT, () => this._onDidInputChange.fire()));
949
this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.KEY_DOWN, (e) => {
950
const keyEvent = new StandardKeyboardEvent(e);
951
if (keyEvent.keyCode === KeyCode.LeftArrow || keyEvent.keyCode === KeyCode.RightArrow) {
952
this._onDidInputChange.fire();
953
}
954
}));
955
this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.CLICK, () => this._onDidInputChange.fire()));
956
957
// focus "container" border instead of input box
958
959
this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.FOCUS, () => {
960
this.domNode.style.outlineWidth = '1px';
961
this.domNode.style.outlineStyle = 'solid';
962
this.domNode.style.outlineOffset = '-1px';
963
this.domNode.style.outlineColor = 'var(--vscode-focusBorder)';
964
}));
965
this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.BLUR, () => {
966
this.domNode.style.outline = 'none';
967
}));
968
}
969
return this._domNode;
970
}
971
972
get input() {
973
assertType(this._inputNode);
974
return this._inputNode;
975
}
976
977
get button() {
978
assertType(this._buttonNode);
979
return this._buttonNode;
980
}
981
982
get buttonState() {
983
return this._buttonState;
984
}
985
986
setSparkleButton() {
987
this._buttonState = 'sparkle';
988
this._sparkleIcon ??= renderIcon(Codicon.sparkle);
989
dom.clearNode(this.button);
990
this.button.appendChild(this._sparkleIcon);
991
this.button.setAttribute('aria-label', 'Generating new name suggestions');
992
this._buttonHoverContent = this._buttonGenHoverText!;
993
this.input.focus();
994
}
995
996
setStopButton() {
997
this._buttonState = 'stop';
998
this._stopIcon ??= renderIcon(Codicon.stopCircle);
999
dom.clearNode(this.button);
1000
this.button.appendChild(this._stopIcon);
1001
this.button.setAttribute('aria-label', 'Cancel generating new name suggestions');
1002
this._buttonHoverContent = this._buttonCancelHoverText!;
1003
this.input.focus();
1004
}
1005
1006
dispose(): void {
1007
this._disposables.dispose();
1008
}
1009
}
1010
1011
class RenameCandidateView {
1012
1013
private static _PADDING: number = 2;
1014
1015
private readonly _domNode: HTMLElement;
1016
private readonly _icon: HTMLElement;
1017
private readonly _label: HTMLElement;
1018
1019
constructor(parent: HTMLElement, fontInfo: FontInfo) {
1020
1021
this._domNode = document.createElement('div');
1022
this._domNode.className = 'rename-box rename-candidate';
1023
this._domNode.style.display = `flex`;
1024
this._domNode.style.columnGap = `5px`;
1025
this._domNode.style.alignItems = `center`;
1026
this._domNode.style.height = `${fontInfo.lineHeight}px`;
1027
this._domNode.style.padding = `${RenameCandidateView._PADDING}px`;
1028
1029
// @ulugbekna: needed to keep space when the `icon.style.display` is set to `none`
1030
const iconContainer = document.createElement('div');
1031
iconContainer.style.display = `flex`;
1032
iconContainer.style.alignItems = `center`;
1033
iconContainer.style.width = iconContainer.style.height = `${fontInfo.lineHeight * 0.8}px`;
1034
this._domNode.appendChild(iconContainer);
1035
1036
this._icon = renderIcon(Codicon.sparkle);
1037
this._icon.style.display = `none`;
1038
iconContainer.appendChild(this._icon);
1039
1040
this._label = document.createElement('div');
1041
domFontInfo.applyFontInfo(this._label, fontInfo);
1042
this._domNode.appendChild(this._label);
1043
1044
parent.appendChild(this._domNode);
1045
}
1046
1047
public populate(value: NewSymbolName) {
1048
this._updateIcon(value);
1049
this._updateLabel(value);
1050
}
1051
1052
private _updateIcon(value: NewSymbolName) {
1053
const isAIGenerated = !!value.tags?.includes(NewSymbolNameTag.AIGenerated);
1054
this._icon.style.display = isAIGenerated ? 'inherit' : 'none';
1055
}
1056
1057
private _updateLabel(value: NewSymbolName) {
1058
this._label.innerText = value.newSymbolName;
1059
}
1060
1061
public static getLayoutInfo({ lineHeight }: { lineHeight: number }): { totalHeight: number } {
1062
const totalHeight = lineHeight + RenameCandidateView._PADDING * 2 /* top & bottom padding */;
1063
return { totalHeight };
1064
}
1065
1066
public dispose() {
1067
}
1068
}
1069
1070