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