Path: blob/main/src/vs/editor/contrib/rename/browser/renameWidget.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as dom from '../../../../base/browser/dom.js';6import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';7import * as aria from '../../../../base/browser/ui/aria/aria.js';8import { getBaseLayerHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate2.js';9import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';10import { IListRenderer, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';11import { List } from '../../../../base/browser/ui/list/listWidget.js';12import * as arrays from '../../../../base/common/arrays.js';13import { DeferredPromise, raceCancellation } from '../../../../base/common/async.js';14import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';15import { Codicon } from '../../../../base/common/codicons.js';16import { Emitter } from '../../../../base/common/event.js';17import { KeyCode } from '../../../../base/common/keyCodes.js';18import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';19import { StopWatch } from '../../../../base/common/stopwatch.js';20import { assertType, isDefined } from '../../../../base/common/types.js';21import './renameWidget.css';22import * as domFontInfo from '../../../browser/config/domFontInfo.js';23import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from '../../../browser/editorBrowser.js';24import { EditorOption } from '../../../common/config/editorOptions.js';25import { FontInfo } from '../../../common/config/fontInfo.js';26import { IDimension } from '../../../common/core/2d/dimension.js';27import { Position } from '../../../common/core/position.js';28import { IRange, Range } from '../../../common/core/range.js';29import { ScrollType } from '../../../common/editorCommon.js';30import { NewSymbolName, NewSymbolNameTag, NewSymbolNameTriggerKind, ProviderResult } from '../../../common/languages.js';31import * as nls from '../../../../nls.js';32import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';33import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';34import { ILogService } from '../../../../platform/log/common/log.js';35import { getListStyles } from '../../../../platform/theme/browser/defaultStyles.js';36import {37editorWidgetBackground,38inputBackground,39inputBorder,40inputForeground,41quickInputListFocusBackground,42quickInputListFocusForeground,43widgetBorder,44widgetShadow45} from '../../../../platform/theme/common/colorRegistry.js';46import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js';4748/** for debugging */49const _sticky = false50// || Boolean("true") // done "weirdly" so that a lint warning prevents you from pushing this51;525354export const CONTEXT_RENAME_INPUT_VISIBLE = new RawContextKey<boolean>('renameInputVisible', false, nls.localize('renameInputVisible', "Whether the rename input widget is visible"));55export const CONTEXT_RENAME_INPUT_FOCUSED = new RawContextKey<boolean>('renameInputFocused', false, nls.localize('renameInputFocused', "Whether the rename input widget is focused"));5657/**58* "Source" of the new name:59* - 'inputField' - user entered the new name60* - 'renameSuggestion' - user picked from rename suggestions61* - 'userEditedRenameSuggestion' - user _likely_ edited a rename suggestion ("likely" because when input started being edited, a rename suggestion had focus)62*/63export type NewNameSource =64| { k: 'inputField' }65| { k: 'renameSuggestion' }66| { k: 'userEditedRenameSuggestion' };6768/**69* Various statistics regarding rename input field70*/71export type RenameWidgetStats = {72nRenameSuggestions: number;73source: NewNameSource;74timeBeforeFirstInputFieldEdit: number | undefined;75nRenameSuggestionsInvocations: number;76hadAutomaticRenameSuggestionsInvocation: boolean;77};7879export type RenameWidgetResult = {80/**81* The new name to be used82*/83newName: string;84wantsPreview?: boolean;85stats: RenameWidgetStats;86};8788interface IRenameWidget {89/**90* @returns a `boolean` standing for `shouldFocusEditor`, if user didn't pick a new name, or a {@link RenameWidgetResult}91*/92getInput(93where: IRange,94currentName: string,95supportPreview: boolean,96requestRenameSuggestions: (triggerKind: NewSymbolNameTriggerKind, cts: CancellationToken) => ProviderResult<NewSymbolName[]>[],97cts: CancellationTokenSource98): Promise<RenameWidgetResult | boolean>;99100acceptInput(wantsPreview: boolean): void;101cancelInput(focusEditor: boolean, caller: string): void;102103focusNextRenameSuggestion(): void;104focusPreviousRenameSuggestion(): void;105}106107export class RenameWidget implements IRenameWidget, IContentWidget, IDisposable {108109// implement IContentWidget110readonly allowEditorOverflow: boolean = true;111112// UI state113114private _domNode?: HTMLElement;115private _inputWithButton: InputWithButton;116private _renameCandidateListView?: RenameCandidateListView;117private _label?: HTMLDivElement;118119private _nPxAvailableAbove?: number;120private _nPxAvailableBelow?: number;121122// Model state123124private _position?: Position;125private _currentName?: string;126/** Is true if input field got changes when a rename candidate was focused; otherwise, false */127private _isEditingRenameCandidate: boolean;128129private readonly _candidates: Set<string>;130131private _visible?: boolean;132133/** must be reset at session start */134private _beforeFirstInputFieldEditSW: StopWatch;135136/**137* Milliseconds before user edits the input field for the first time138* @remarks must be set once per session139*/140private _timeBeforeFirstInputFieldEdit: number | undefined;141142private _nRenameSuggestionsInvocations: number;143144private _hadAutomaticRenameSuggestionsInvocation: boolean;145146private _renameCandidateProvidersCts: CancellationTokenSource | undefined;147private _renameCts: CancellationTokenSource | undefined;148149private readonly _visibleContextKey: IContextKey<boolean>;150private readonly _disposables = new DisposableStore();151152constructor(153private readonly _editor: ICodeEditor,154private readonly _acceptKeybindings: [string, string],155@IThemeService private readonly _themeService: IThemeService,156@IKeybindingService private readonly _keybindingService: IKeybindingService,157@IContextKeyService contextKeyService: IContextKeyService,158@ILogService private readonly _logService: ILogService,159) {160this._visibleContextKey = CONTEXT_RENAME_INPUT_VISIBLE.bindTo(contextKeyService);161162this._isEditingRenameCandidate = false;163164this._nRenameSuggestionsInvocations = 0;165166this._hadAutomaticRenameSuggestionsInvocation = false;167168this._candidates = new Set();169170this._beforeFirstInputFieldEditSW = new StopWatch();171172this._inputWithButton = new InputWithButton();173this._disposables.add(this._inputWithButton);174175this._editor.addContentWidget(this);176177this._disposables.add(this._editor.onDidChangeConfiguration(e => {178if (e.hasChanged(EditorOption.fontInfo)) {179this._updateFont();180}181}));182183this._disposables.add(_themeService.onDidColorThemeChange(this._updateStyles, this));184}185186dispose(): void {187this._disposables.dispose();188this._editor.removeContentWidget(this);189}190191getId(): string {192return '__renameInputWidget';193}194195getDomNode(): HTMLElement {196if (!this._domNode) {197this._domNode = document.createElement('div');198this._domNode.className = 'monaco-editor rename-box';199200this._domNode.appendChild(this._inputWithButton.domNode);201202this._renameCandidateListView = this._disposables.add(203new RenameCandidateListView(this._domNode, {204fontInfo: this._editor.getOption(EditorOption.fontInfo),205onFocusChange: (newSymbolName: string) => {206this._inputWithButton.input.value = newSymbolName;207this._isEditingRenameCandidate = false; // @ulugbekna: reset208},209onSelectionChange: () => {210this._isEditingRenameCandidate = false; // @ulugbekna: because user picked a rename suggestion211this.acceptInput(false); // we don't allow preview with mouse click for now212}213})214);215216this._disposables.add(217this._inputWithButton.onDidInputChange(() => {218if (this._renameCandidateListView?.focusedCandidate !== undefined) {219this._isEditingRenameCandidate = true;220}221this._timeBeforeFirstInputFieldEdit ??= this._beforeFirstInputFieldEditSW.elapsed();222if (this._renameCandidateProvidersCts?.token.isCancellationRequested === false) {223this._renameCandidateProvidersCts.cancel();224}225this._renameCandidateListView?.clearFocus();226})227);228229this._label = document.createElement('div');230this._label.className = 'rename-label';231this._domNode.appendChild(this._label);232233this._updateFont();234this._updateStyles(this._themeService.getColorTheme());235}236return this._domNode;237}238239private _updateStyles(theme: IColorTheme): void {240if (!this._domNode) {241return;242}243244const widgetShadowColor = theme.getColor(widgetShadow);245const widgetBorderColor = theme.getColor(widgetBorder);246this._domNode.style.backgroundColor = String(theme.getColor(editorWidgetBackground) ?? '');247this._domNode.style.boxShadow = widgetShadowColor ? ` 0 0 8px 2px ${widgetShadowColor}` : '';248this._domNode.style.border = widgetBorderColor ? `1px solid ${widgetBorderColor}` : '';249this._domNode.style.color = String(theme.getColor(inputForeground) ?? '');250251const border = theme.getColor(inputBorder);252253this._inputWithButton.domNode.style.backgroundColor = String(theme.getColor(inputBackground) ?? '');254this._inputWithButton.input.style.backgroundColor = String(theme.getColor(inputBackground) ?? '');255this._inputWithButton.domNode.style.borderWidth = border ? '1px' : '0px';256this._inputWithButton.domNode.style.borderStyle = border ? 'solid' : 'none';257this._inputWithButton.domNode.style.borderColor = border?.toString() ?? 'none';258}259260private _updateFont(): void {261if (this._domNode === undefined) {262return;263}264assertType(this._label !== undefined, 'RenameWidget#_updateFont: _label must not be undefined given _domNode is defined');265266this._editor.applyFontInfo(this._inputWithButton.input);267268const fontInfo = this._editor.getOption(EditorOption.fontInfo);269this._label.style.fontSize = `${this._computeLabelFontSize(fontInfo.fontSize)}px`;270}271272private _computeLabelFontSize(editorFontSize: number) {273return editorFontSize * 0.8;274}275276getPosition(): IContentWidgetPosition | null {277if (!this._visible) {278return null;279}280281if (!this._editor.hasModel() || // @ulugbekna: shouldn't happen282!this._editor.getDomNode() // @ulugbekna: can happen during tests based on suggestWidget's similar predicate check283) {284return null;285}286287const bodyBox = dom.getClientArea(this.getDomNode().ownerDocument.body);288const editorBox = dom.getDomNodePagePosition(this._editor.getDomNode());289290const cursorBoxTop = this._getTopForPosition();291292this._nPxAvailableAbove = cursorBoxTop + editorBox.top;293this._nPxAvailableBelow = bodyBox.height - this._nPxAvailableAbove;294295const lineHeight = this._editor.getOption(EditorOption.lineHeight);296const { totalHeight: candidateViewHeight } = RenameCandidateView.getLayoutInfo({ lineHeight });297298const positionPreference = this._nPxAvailableBelow > candidateViewHeight * 6 /* approximate # of candidates to fit in (inclusive of rename input box & rename label) */299? [ContentWidgetPositionPreference.BELOW, ContentWidgetPositionPreference.ABOVE]300: [ContentWidgetPositionPreference.ABOVE, ContentWidgetPositionPreference.BELOW];301302return {303position: this._position!,304preference: positionPreference,305};306}307308beforeRender(): IDimension | null {309const [accept, preview] = this._acceptKeybindings;310this._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());311312this._domNode!.style.minWidth = `200px`; // to prevent from widening when candidates come in313314return null;315}316317afterRender(position: ContentWidgetPositionPreference | null): void {318// FIXME@ulugbekna: commenting trace log out until we start unmounting the widget from editor properly - https://github.com/microsoft/vscode/issues/226975319// this._trace('invoking afterRender, position: ', position ? 'not null' : 'null');320if (position === null) {321// cancel rename when input widget isn't rendered anymore322this.cancelInput(true, 'afterRender (because position is null)');323return;324}325326if (!this._editor.hasModel() || // shouldn't happen327!this._editor.getDomNode() // can happen during tests based on suggestWidget's similar predicate check328) {329return;330}331332assertType(this._renameCandidateListView);333assertType(this._nPxAvailableAbove !== undefined);334assertType(this._nPxAvailableBelow !== undefined);335336const inputBoxHeight = dom.getTotalHeight(this._inputWithButton.domNode);337338const labelHeight = dom.getTotalHeight(this._label!);339340let totalHeightAvailable: number;341if (position === ContentWidgetPositionPreference.BELOW) {342totalHeightAvailable = this._nPxAvailableBelow;343} else {344totalHeightAvailable = this._nPxAvailableAbove;345}346347this._renameCandidateListView!.layout({348height: totalHeightAvailable - labelHeight - inputBoxHeight,349width: dom.getTotalWidth(this._inputWithButton.domNode),350});351}352353354private _currentAcceptInput?: (wantsPreview: boolean) => void;355private _currentCancelInput?: (focusEditor: boolean) => void;356private _requestRenameCandidatesOnce?: (triggerKind: NewSymbolNameTriggerKind, cts: CancellationToken) => ProviderResult<NewSymbolName[]>[];357358acceptInput(wantsPreview: boolean): void {359this._trace(`invoking acceptInput`);360this._currentAcceptInput?.(wantsPreview);361}362363cancelInput(focusEditor: boolean, caller: string): void {364// this._trace(`invoking cancelInput, caller: ${caller}, _currentCancelInput: ${this._currentAcceptInput ? 'not undefined' : 'undefined'}`);365this._currentCancelInput?.(focusEditor);366}367368focusNextRenameSuggestion() {369if (!this._renameCandidateListView?.focusNext()) {370this._inputWithButton.input.value = this._currentName!;371}372}373374focusPreviousRenameSuggestion() { // TODO@ulugbekna: this and focusNext should set the original name if no candidate is focused375if (!this._renameCandidateListView?.focusPrevious()) {376this._inputWithButton.input.value = this._currentName!;377}378}379380/**381* @param requestRenameCandidates is `undefined` when there are no rename suggestion providers382*/383getInput(384where: IRange,385currentName: string,386supportPreview: boolean,387requestRenameCandidates: undefined | ((triggerKind: NewSymbolNameTriggerKind, cts: CancellationToken) => ProviderResult<NewSymbolName[]>[]),388cts: CancellationTokenSource389): Promise<RenameWidgetResult | boolean> {390391const { start: selectionStart, end: selectionEnd } = this._getSelection(where, currentName);392393this._renameCts = cts;394395const disposeOnDone = new DisposableStore();396397this._nRenameSuggestionsInvocations = 0;398399this._hadAutomaticRenameSuggestionsInvocation = false;400401if (requestRenameCandidates === undefined) {402this._inputWithButton.button.style.display = 'none';403} else {404this._inputWithButton.button.style.display = 'flex';405406this._requestRenameCandidatesOnce = requestRenameCandidates;407408this._requestRenameCandidates(currentName, false);409410disposeOnDone.add(dom.addDisposableListener(411this._inputWithButton.button,412'click',413() => this._requestRenameCandidates(currentName, true)414));415disposeOnDone.add(dom.addDisposableListener(416this._inputWithButton.button,417dom.EventType.KEY_DOWN,418(e) => {419const keyEvent = new StandardKeyboardEvent(e);420421if (keyEvent.equals(KeyCode.Enter) || keyEvent.equals(KeyCode.Space)) {422keyEvent.stopPropagation();423keyEvent.preventDefault();424this._requestRenameCandidates(currentName, true);425}426}427));428}429430this._isEditingRenameCandidate = false;431432this._domNode!.classList.toggle('preview', supportPreview);433434this._position = new Position(where.startLineNumber, where.startColumn);435this._currentName = currentName;436437this._inputWithButton.input.value = currentName;438this._inputWithButton.input.setAttribute('selectionStart', selectionStart.toString());439this._inputWithButton.input.setAttribute('selectionEnd', selectionEnd.toString());440this._inputWithButton.input.size = Math.max((where.endColumn - where.startColumn) * 1.1, 20); // determines width441442this._beforeFirstInputFieldEditSW.reset();443444445disposeOnDone.add(toDisposable(() => {446this._renameCts = undefined;447cts.dispose(true);448})); // @ulugbekna: this may result in `this.cancelInput` being called twice, but it should be safe since we set it to undefined after 1st call449disposeOnDone.add(toDisposable(() => {450if (this._renameCandidateProvidersCts !== undefined) {451this._renameCandidateProvidersCts.dispose(true);452this._renameCandidateProvidersCts = undefined;453}454}));455456disposeOnDone.add(toDisposable(() => this._candidates.clear()));457458const inputResult = new DeferredPromise<RenameWidgetResult | boolean>();459460inputResult.p.finally(() => {461disposeOnDone.dispose();462this._hide();463});464465this._currentCancelInput = (focusEditor) => {466this._trace('invoking _currentCancelInput');467this._currentAcceptInput = undefined;468this._currentCancelInput = undefined;469// fixme session cleanup470this._renameCandidateListView?.clearCandidates();471inputResult.complete(focusEditor);472return true;473};474475this._currentAcceptInput = (wantsPreview) => {476this._trace('invoking _currentAcceptInput');477assertType(this._renameCandidateListView !== undefined);478479const nRenameSuggestions = this._renameCandidateListView.nCandidates;480481let newName: string;482let source: NewNameSource;483const focusedCandidate = this._renameCandidateListView.focusedCandidate;484if (focusedCandidate !== undefined) {485this._trace('using new name from renameSuggestion');486newName = focusedCandidate;487source = { k: 'renameSuggestion' };488} else {489this._trace('using new name from inputField');490newName = this._inputWithButton.input.value;491source = this._isEditingRenameCandidate ? { k: 'userEditedRenameSuggestion' } : { k: 'inputField' };492}493494if (newName === currentName || newName.trim().length === 0 /* is just whitespace */) {495this.cancelInput(true, '_currentAcceptInput (because newName === value || newName.trim().length === 0)');496return;497}498499this._currentAcceptInput = undefined;500this._currentCancelInput = undefined;501this._renameCandidateListView.clearCandidates();502// fixme session cleanup503504inputResult.complete({505newName,506wantsPreview: supportPreview && wantsPreview,507stats: {508source,509nRenameSuggestions,510timeBeforeFirstInputFieldEdit: this._timeBeforeFirstInputFieldEdit,511nRenameSuggestionsInvocations: this._nRenameSuggestionsInvocations,512hadAutomaticRenameSuggestionsInvocation: this._hadAutomaticRenameSuggestionsInvocation,513}514});515};516517disposeOnDone.add(cts.token.onCancellationRequested(() => this.cancelInput(true, 'cts.token.onCancellationRequested')));518if (!_sticky) {519disposeOnDone.add(this._editor.onDidBlurEditorWidget(() => this.cancelInput(!this._domNode?.ownerDocument.hasFocus(), 'editor.onDidBlurEditorWidget')));520}521522this._show();523524return inputResult.p;525}526527private _requestRenameCandidates(currentName: string, isManuallyTriggered: boolean) {528if (this._requestRenameCandidatesOnce === undefined) {529return;530}531if (this._renameCandidateProvidersCts !== undefined) {532this._renameCandidateProvidersCts.dispose(true);533}534535assertType(this._renameCts);536537if (this._inputWithButton.buttonState !== 'stop') {538539this._renameCandidateProvidersCts = new CancellationTokenSource();540541const triggerKind = isManuallyTriggered ? NewSymbolNameTriggerKind.Invoke : NewSymbolNameTriggerKind.Automatic;542const candidates = this._requestRenameCandidatesOnce(triggerKind, this._renameCandidateProvidersCts.token);543544if (candidates.length === 0) {545this._inputWithButton.setSparkleButton();546return;547}548549if (!isManuallyTriggered) {550this._hadAutomaticRenameSuggestionsInvocation = true;551}552553this._nRenameSuggestionsInvocations += 1;554555this._inputWithButton.setStopButton();556557this._updateRenameCandidates(candidates, currentName, this._renameCts.token);558}559}560561/**562* This allows selecting only part of the symbol name in the input field based on the selection in the editor563*/564private _getSelection(where: IRange, currentName: string): { start: number; end: number } {565assertType(this._editor.hasModel());566567const selection = this._editor.getSelection();568let start = 0;569let end = currentName.length;570571if (!Range.isEmpty(selection) && !Range.spansMultipleLines(selection) && Range.containsRange(where, selection)) {572start = Math.max(0, selection.startColumn - where.startColumn);573end = Math.min(where.endColumn, selection.endColumn) - where.startColumn;574}575576return { start, end };577}578579private _show(): void {580this._trace('invoking _show');581this._editor.revealLineInCenterIfOutsideViewport(this._position!.lineNumber, ScrollType.Smooth);582this._visible = true;583this._visibleContextKey.set(true);584this._editor.layoutContentWidget(this);585586// TODO@ulugbekna: could this be simply run in `afterRender`?587setTimeout(() => {588this._inputWithButton.input.focus();589this._inputWithButton.input.setSelectionRange(590parseInt(this._inputWithButton.input.getAttribute('selectionStart')!),591parseInt(this._inputWithButton.input.getAttribute('selectionEnd')!)592);593}, 100);594}595596private async _updateRenameCandidates(candidates: ProviderResult<NewSymbolName[]>[], currentName: string, token: CancellationToken) {597const trace = (...args: any[]) => this._trace('_updateRenameCandidates', ...args);598599trace('start');600const namesListResults = await raceCancellation(Promise.allSettled(candidates), token);601602this._inputWithButton.setSparkleButton();603604if (namesListResults === undefined) {605trace('returning early - received updateRenameCandidates results - undefined');606return;607}608609const newNames = namesListResults.flatMap(namesListResult =>610namesListResult.status === 'fulfilled' && isDefined(namesListResult.value)611? namesListResult.value612: []613);614trace(`received updateRenameCandidates results - total (unfiltered) ${newNames.length} candidates.`);615616// deduplicate and filter out the current value617618const distinctNames = arrays.distinct(newNames, v => v.newSymbolName);619trace(`distinct candidates - ${distinctNames.length} candidates.`);620621const validDistinctNames = distinctNames.filter(({ newSymbolName }) => newSymbolName.trim().length > 0 && newSymbolName !== this._inputWithButton.input.value && newSymbolName !== currentName && !this._candidates.has(newSymbolName));622trace(`valid distinct candidates - ${newNames.length} candidates.`);623624validDistinctNames.forEach(n => this._candidates.add(n.newSymbolName));625626if (validDistinctNames.length < 1) {627trace('returning early - no valid distinct candidates');628return;629}630631// show the candidates632trace('setting candidates');633this._renameCandidateListView!.setCandidates(validDistinctNames);634635// ask editor to re-layout given that the widget is now of a different size after rendering rename candidates636trace('asking editor to re-layout');637this._editor.layoutContentWidget(this);638}639640private _hide(): void {641this._trace('invoked _hide');642this._visible = false;643this._visibleContextKey.reset();644this._editor.layoutContentWidget(this);645}646647private _getTopForPosition(): number {648const visibleRanges = this._editor.getVisibleRanges();649let firstLineInViewport: number;650if (visibleRanges.length > 0) {651firstLineInViewport = visibleRanges[0].startLineNumber;652} else {653this._logService.warn('RenameWidget#_getTopForPosition: this should not happen - visibleRanges is empty');654firstLineInViewport = Math.max(1, this._position!.lineNumber - 5); // @ulugbekna: fallback to current line minus 5655}656return this._editor.getTopForLineNumber(this._position!.lineNumber) - this._editor.getTopForLineNumber(firstLineInViewport);657}658659private _trace(...args: unknown[]) {660this._logService.trace('RenameWidget', ...args);661}662}663664class RenameCandidateListView {665666/** Parent node of the list widget; needed to control # of list elements visible */667private readonly _listContainer: HTMLDivElement;668private readonly _listWidget: List<NewSymbolName>;669670private _lineHeight: number;671private _availableHeight: number;672private _minimumWidth: number;673private _typicalHalfwidthCharacterWidth: number;674675private readonly _disposables: DisposableStore;676677// FIXME@ulugbekna: rewrite using event emitters678constructor(parent: HTMLElement, opts: { fontInfo: FontInfo; onFocusChange: (newSymbolName: string) => void; onSelectionChange: () => void }) {679680this._disposables = new DisposableStore();681682this._availableHeight = 0;683this._minimumWidth = 0;684685this._lineHeight = opts.fontInfo.lineHeight;686this._typicalHalfwidthCharacterWidth = opts.fontInfo.typicalHalfwidthCharacterWidth;687688this._listContainer = document.createElement('div');689this._listContainer.className = 'rename-box rename-candidate-list-container';690parent.appendChild(this._listContainer);691692this._listWidget = RenameCandidateListView._createListWidget(this._listContainer, this._candidateViewHeight, opts.fontInfo);693694this._disposables.add(this._listWidget.onDidChangeFocus(695e => {696if (e.elements.length === 1) {697opts.onFocusChange(e.elements[0].newSymbolName);698}699},700this._disposables701));702703this._disposables.add(this._listWidget.onDidChangeSelection(704e => {705if (e.elements.length === 1) {706opts.onSelectionChange();707}708},709this._disposables710));711712this._disposables.add(713this._listWidget.onDidBlur(e => { // @ulugbekna: because list widget otherwise remembers last focused element and returns it as focused element714this._listWidget.setFocus([]);715})716);717718this._listWidget.style(getListStyles({719listInactiveFocusForeground: quickInputListFocusForeground,720listInactiveFocusBackground: quickInputListFocusBackground,721}));722}723724dispose() {725this._listWidget.dispose();726this._disposables.dispose();727}728729// height - max height allowed by parent element730public layout({ height, width }: { height: number; width: number }): void {731this._availableHeight = height;732this._minimumWidth = width;733}734735public setCandidates(candidates: NewSymbolName[]): void {736737// insert candidates into list widget738this._listWidget.splice(0, 0, candidates);739740// adjust list widget layout741const height = this._pickListHeight(this._listWidget.length);742const width = this._pickListWidth(candidates);743744this._listWidget.layout(height, width);745746// adjust list container layout747this._listContainer.style.height = `${height}px`;748this._listContainer.style.width = `${width}px`;749750aria.status(nls.localize('renameSuggestionsReceivedAria', "Received {0} rename suggestions", candidates.length));751}752753public clearCandidates(): void {754this._listContainer.style.height = '0px';755this._listContainer.style.width = '0px';756this._listWidget.splice(0, this._listWidget.length, []);757}758759public get nCandidates() {760return this._listWidget.length;761}762763public get focusedCandidate(): string | undefined {764if (this._listWidget.length === 0) {765return;766}767const selectedElement = this._listWidget.getSelectedElements()[0];768if (selectedElement !== undefined) {769return selectedElement.newSymbolName;770}771const focusedElement = this._listWidget.getFocusedElements()[0];772if (focusedElement !== undefined) {773return focusedElement.newSymbolName;774}775return;776}777778public focusNext(): boolean {779if (this._listWidget.length === 0) {780return false;781}782const focusedIxs = this._listWidget.getFocus();783if (focusedIxs.length === 0) {784this._listWidget.focusFirst();785this._listWidget.reveal(0);786return true;787} else {788if (focusedIxs[0] === this._listWidget.length - 1) {789this._listWidget.setFocus([]);790this._listWidget.reveal(0); // @ulugbekna: without this, it seems like focused element is obstructed791return false;792} else {793this._listWidget.focusNext();794const focused = this._listWidget.getFocus()[0];795this._listWidget.reveal(focused);796return true;797}798}799}800801/**802* @returns true if focus is moved to previous element803*/804public focusPrevious(): boolean {805if (this._listWidget.length === 0) {806return false;807}808const focusedIxs = this._listWidget.getFocus();809if (focusedIxs.length === 0) {810this._listWidget.focusLast();811const focused = this._listWidget.getFocus()[0];812this._listWidget.reveal(focused);813return true;814} else {815if (focusedIxs[0] === 0) {816this._listWidget.setFocus([]);817return false;818} else {819this._listWidget.focusPrevious();820const focused = this._listWidget.getFocus()[0];821this._listWidget.reveal(focused);822return true;823}824}825}826827public clearFocus(): void {828this._listWidget.setFocus([]);829}830831private get _candidateViewHeight(): number {832const { totalHeight } = RenameCandidateView.getLayoutInfo({ lineHeight: this._lineHeight });833return totalHeight;834}835836private _pickListHeight(nCandidates: number) {837const heightToFitAllCandidates = this._candidateViewHeight * nCandidates;838const MAX_N_CANDIDATES = 7; // @ulugbekna: max # of candidates we want to show at once839const height = Math.min(heightToFitAllCandidates, this._availableHeight, this._candidateViewHeight * MAX_N_CANDIDATES);840return height;841}842843private _pickListWidth(candidates: NewSymbolName[]): number {844const longestCandidateWidth = Math.ceil(Math.max(...candidates.map(c => c.newSymbolName.length)) * this._typicalHalfwidthCharacterWidth);845const width = Math.max(846this._minimumWidth,8474 /* padding */ + 16 /* sparkle icon */ + 5 /* margin-left */ + longestCandidateWidth + 10 /* (possibly visible) scrollbar width */ // TODO@ulugbekna: approximate calc - clean this up848);849return width;850}851852private static _createListWidget(container: HTMLElement, candidateViewHeight: number, fontInfo: FontInfo) {853const virtualDelegate = new class implements IListVirtualDelegate<NewSymbolName> {854getTemplateId(element: NewSymbolName): string {855return 'candidate';856}857858getHeight(element: NewSymbolName): number {859return candidateViewHeight;860}861};862863const renderer = new class implements IListRenderer<NewSymbolName, RenameCandidateView> {864readonly templateId = 'candidate';865866renderTemplate(container: HTMLElement): RenameCandidateView {867return new RenameCandidateView(container, fontInfo);868}869870renderElement(candidate: NewSymbolName, index: number, templateData: RenameCandidateView): void {871templateData.populate(candidate);872}873874disposeTemplate(templateData: RenameCandidateView): void {875templateData.dispose();876}877};878879return new List(880'NewSymbolNameCandidates',881container,882virtualDelegate,883[renderer],884{885keyboardSupport: false, // @ulugbekna: because we handle keyboard events through proper commands & keybinding service, see `rename.ts`886mouseSupport: true,887multipleSelectionSupport: false,888}889);890}891}892893class InputWithButton implements IDisposable {894895private _buttonState: 'sparkle' | 'stop' | undefined;896897private _domNode: HTMLDivElement | undefined;898private _inputNode: HTMLInputElement | undefined;899private _buttonNode: HTMLElement | undefined;900private _buttonHoverContent: string = '';901private _buttonGenHoverText: string | undefined;902private _buttonCancelHoverText: string | undefined;903private _sparkleIcon: HTMLElement | undefined;904private _stopIcon: HTMLElement | undefined;905906private readonly _onDidInputChange = new Emitter<void>();907public readonly onDidInputChange = this._onDidInputChange.event;908909private readonly _disposables = new DisposableStore();910911get domNode() {912if (!this._domNode) {913914this._domNode = document.createElement('div');915this._domNode.className = 'rename-input-with-button';916this._domNode.style.display = 'flex';917this._domNode.style.flexDirection = 'row';918this._domNode.style.alignItems = 'center';919920this._inputNode = document.createElement('input');921this._inputNode.className = 'rename-input';922this._inputNode.type = 'text';923this._inputNode.style.border = 'none';924this._inputNode.setAttribute('aria-label', nls.localize('renameAriaLabel', "Rename input. Type new name and press Enter to commit."));925926this._domNode.appendChild(this._inputNode);927928this._buttonNode = document.createElement('div');929this._buttonNode.className = 'rename-suggestions-button';930this._buttonNode.setAttribute('tabindex', '0');931932this._buttonGenHoverText = nls.localize('generateRenameSuggestionsButton', "Generate new name suggestions");933this._buttonCancelHoverText = nls.localize('cancelRenameSuggestionsButton', "Cancel");934this._buttonHoverContent = this._buttonGenHoverText;935this._disposables.add(getBaseLayerHoverDelegate().setupDelayedHover(this._buttonNode, () => ({936content: this._buttonHoverContent,937appearance: {938showPointer: true,939compact: true,940}941})));942943this._domNode.appendChild(this._buttonNode);944945// notify if selection changes to cancel request to rename-suggestion providers946947this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.INPUT, () => this._onDidInputChange.fire()));948this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.KEY_DOWN, (e) => {949const keyEvent = new StandardKeyboardEvent(e);950if (keyEvent.keyCode === KeyCode.LeftArrow || keyEvent.keyCode === KeyCode.RightArrow) {951this._onDidInputChange.fire();952}953}));954this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.CLICK, () => this._onDidInputChange.fire()));955956// focus "container" border instead of input box957958this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.FOCUS, () => {959this.domNode.style.outlineWidth = '1px';960this.domNode.style.outlineStyle = 'solid';961this.domNode.style.outlineOffset = '-1px';962this.domNode.style.outlineColor = 'var(--vscode-focusBorder)';963}));964this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.BLUR, () => {965this.domNode.style.outline = 'none';966}));967}968return this._domNode;969}970971get input() {972assertType(this._inputNode);973return this._inputNode;974}975976get button() {977assertType(this._buttonNode);978return this._buttonNode;979}980981get buttonState() {982return this._buttonState;983}984985setSparkleButton() {986this._buttonState = 'sparkle';987this._sparkleIcon ??= renderIcon(Codicon.sparkle);988dom.clearNode(this.button);989this.button.appendChild(this._sparkleIcon);990this.button.setAttribute('aria-label', 'Generating new name suggestions');991this._buttonHoverContent = this._buttonGenHoverText!;992this.input.focus();993}994995setStopButton() {996this._buttonState = 'stop';997this._stopIcon ??= renderIcon(Codicon.stopCircle);998dom.clearNode(this.button);999this.button.appendChild(this._stopIcon);1000this.button.setAttribute('aria-label', 'Cancel generating new name suggestions');1001this._buttonHoverContent = this._buttonCancelHoverText!;1002this.input.focus();1003}10041005dispose(): void {1006this._disposables.dispose();1007}1008}10091010class RenameCandidateView {10111012private static _PADDING: number = 2;10131014private readonly _domNode: HTMLElement;1015private readonly _icon: HTMLElement;1016private readonly _label: HTMLElement;10171018constructor(parent: HTMLElement, fontInfo: FontInfo) {10191020this._domNode = document.createElement('div');1021this._domNode.className = 'rename-box rename-candidate';1022this._domNode.style.display = `flex`;1023this._domNode.style.columnGap = `5px`;1024this._domNode.style.alignItems = `center`;1025this._domNode.style.height = `${fontInfo.lineHeight}px`;1026this._domNode.style.padding = `${RenameCandidateView._PADDING}px`;10271028// @ulugbekna: needed to keep space when the `icon.style.display` is set to `none`1029const iconContainer = document.createElement('div');1030iconContainer.style.display = `flex`;1031iconContainer.style.alignItems = `center`;1032iconContainer.style.width = iconContainer.style.height = `${fontInfo.lineHeight * 0.8}px`;1033this._domNode.appendChild(iconContainer);10341035this._icon = renderIcon(Codicon.sparkle);1036this._icon.style.display = `none`;1037iconContainer.appendChild(this._icon);10381039this._label = document.createElement('div');1040domFontInfo.applyFontInfo(this._label, fontInfo);1041this._domNode.appendChild(this._label);10421043parent.appendChild(this._domNode);1044}10451046public populate(value: NewSymbolName) {1047this._updateIcon(value);1048this._updateLabel(value);1049}10501051private _updateIcon(value: NewSymbolName) {1052const isAIGenerated = !!value.tags?.includes(NewSymbolNameTag.AIGenerated);1053this._icon.style.display = isAIGenerated ? 'inherit' : 'none';1054}10551056private _updateLabel(value: NewSymbolName) {1057this._label.innerText = value.newSymbolName;1058}10591060public static getLayoutInfo({ lineHeight }: { lineHeight: number }): { totalHeight: number } {1061const totalHeight = lineHeight + RenameCandidateView._PADDING * 2 /* top & bottom padding */;1062return { totalHeight };1063}10641065public dispose() {1066}1067}106810691070