rename: show list of rename candidate names, allow tabbing through them and selecting one by pressing 'enter'

This commit is contained in:
Ulugbek Abdullaev 2024-02-05 19:48:00 +01:00
parent 14770d1197
commit bd1536407a
3 changed files with 92 additions and 6 deletions

View file

@ -208,6 +208,13 @@ class RenameController implements IEditorContribution {
// part 2 - do rename at location
const cts2 = new EditorStateCancellationTokenSource(this.editor, CodeEditorStateFlag.Position | CodeEditorStateFlag.Value, loc.range, this._cts.token);
const model = this.editor.getModel(); // @ulugbekna: assumes editor still has a model, otherwise, cts1 should've been cancelled
const newNameCandidates = Promise.all(
this._languageFeaturesService.newSymbolNamesProvider
.all(model)
.map(provider => provider.provideNewSymbolNames(model, loc.range, cts2.token)) // TODO@ulugbekna: make sure this works regardless if the result is then-able
).then((candidates) => candidates.filter((c): c is string[] => !!c).flat());
const selection = this.editor.getSelection();
let selectionStart = 0;
let selectionEnd = loc.text.length;
@ -218,7 +225,7 @@ class RenameController implements IEditorContribution {
}
const supportPreview = this._bulkEditService.hasPreviewHandler() && this._configService.getValue<boolean>(this.editor.getModel().uri, 'editor.rename.enablePreview');
const inputFieldResult = await this._renameInputField.getInput(loc.range, loc.text, selectionStart, selectionEnd, supportPreview, cts2.token);
const inputFieldResult = await this._renameInputField.getInput(loc.range, loc.text, selectionStart, selectionEnd, supportPreview, newNameCandidates, cts2.token);
// no result, only hint to focus the editor or not
if (typeof inputFieldResult === 'boolean') {

View file

@ -23,6 +23,19 @@
opacity: .8;
}
.monaco-editor .rename-box .new-name-candidates-container {
margin-top: 4px;
}
.monaco-editor .rename-box .new-name-candidate {
/* FIXME@ulugbekna: adapt colors to be nice */
background-color: rgb(2, 96, 190);
color: white;
margin: 2px;
padding: 2px;
}
.monaco-editor .rename-box.preview .rename-label {
display: inherit;
}

View file

@ -3,8 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as dom from 'vs/base/browser/dom';
import { CancellationToken } from 'vs/base/common/cancellation';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
import 'vs/css!./renameInputField';
import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
@ -30,6 +31,7 @@ export class RenameInputField implements IContentWidget {
private _position?: Position;
private _domNode?: HTMLElement;
private _input?: HTMLInputElement;
private _newNameCandidates?: NewSymbolNameCandidates;
private _label?: HTMLDivElement;
private _visible?: boolean;
private readonly _visibleContextKey: IContextKey<boolean>;
@ -77,6 +79,11 @@ export class RenameInputField implements IContentWidget {
this._input.setAttribute('aria-label', localize('renameAriaLabel', "Rename input. Type new name and press Enter to commit."));
this._domNode.appendChild(this._input);
// TODO@ulugbekna: add keyboard support for cycling through the candidates
this._newNameCandidates = new NewSymbolNameCandidates();
this._domNode.appendChild(this._newNameCandidates!.domNode);
this._disposables.add(this._newNameCandidates);
this._label = document.createElement('div');
this._label.className = 'rename-label';
this._domNode.appendChild(this._label);
@ -108,7 +115,7 @@ export class RenameInputField implements IContentWidget {
}
private _updateFont(): void {
if (!this._input || !this._label) {
if (!this._input || !this._label || !this._newNameCandidates) {
return;
}
@ -117,6 +124,10 @@ export class RenameInputField implements IContentWidget {
this._input.style.fontWeight = fontInfo.fontWeight;
this._input.style.fontSize = `${fontInfo.fontSize}px`;
this._newNameCandidates.domNode.style.fontFamily = fontInfo.fontFamily;
this._newNameCandidates.domNode.style.fontWeight = fontInfo.fontWeight;
this._newNameCandidates.domNode.style.fontSize = `${fontInfo.fontSize}px`;
this._label.style.fontSize = `${fontInfo.fontSize * 0.8}px`;
}
@ -155,7 +166,7 @@ export class RenameInputField implements IContentWidget {
this._currentCancelInput?.(focusEditor);
}
getInput(where: IRange, value: string, selectionStart: number, selectionEnd: number, supportPreview: boolean, token: CancellationToken): Promise<RenameInputFieldResult | boolean> {
getInput(where: IRange, value: string, selectionStart: number, selectionEnd: number, supportPreview: boolean, newNameCandidates: Promise<string[]>, token: CancellationToken): Promise<RenameInputFieldResult | boolean> {
this._domNode!.classList.toggle('preview', supportPreview);
@ -167,26 +178,39 @@ export class RenameInputField implements IContentWidget {
const disposeOnDone = new DisposableStore();
newNameCandidates.then(candidates => {
this._newNameCandidates!.setCandidates(candidates);
});
return new Promise<RenameInputFieldResult | boolean>(resolve => {
this._currentCancelInput = (focusEditor) => {
this._currentAcceptInput = undefined;
this._currentCancelInput = undefined;
this._newNameCandidates?.clearCandidates();
resolve(focusEditor);
return true;
};
this._currentAcceptInput = (wantsPreview) => {
if (this._input!.value.trim().length === 0 || this._input!.value === value) {
if (this._input!.value.trim().length === 0) {
// empty or whitespace only or not changed
this.cancelInput(true);
return;
}
const selectedCandidate = this._newNameCandidates?.selectedCandidate;
if ((selectedCandidate === undefined && this._input!.value === value) || selectedCandidate === value) {
this.cancelInput(true);
return;
}
this._currentAcceptInput = undefined;
this._currentCancelInput = undefined;
this._newNameCandidates?.clearCandidates();
resolve({
newName: this._input!.value,
newName: selectedCandidate ?? this._input!.value,
wantsPreview: supportPreview && wantsPreview
});
};
@ -222,3 +246,45 @@ export class RenameInputField implements IContentWidget {
this._editor.layoutContentWidget(this);
}
}
class NewSymbolNameCandidates implements IDisposable {
public readonly domNode: HTMLDivElement;
private _candidates: HTMLSpanElement[] = [];
private _disposables = new DisposableStore();
constructor() {
this.domNode = document.createElement('div');
this.domNode.className = 'new-name-candidates-container';
this.domNode.tabIndex = -1; // Make the div unfocusable
}
get selectedCandidate(): string | undefined {
const activeDocument = dom.getActiveDocument();
const activeElement = activeDocument.activeElement;
const index = this._candidates.indexOf(activeElement as HTMLSpanElement);
return index !== -1 ? this._candidates[index].innerText : undefined;
}
setCandidates(candidates: string[]): void {
for (let i = 0; i < candidates.length; i++) {
const candidate = candidates[i];
const candidateElt = document.createElement('span');
candidateElt.className = 'new-name-candidate';
candidateElt.innerText = candidate;
candidateElt.tabIndex = 0;
this.domNode.appendChild(candidateElt);
this._candidates.push(candidateElt);
}
}
clearCandidates(): void {
this.domNode.innerText = ''; // TODO@ulugbekna: make sure this is the right way to clean up children
this._candidates = [];
}
dispose(): void {
this._disposables.dispose();
}
}