Path: blob/main/src/vs/editor/contrib/rename/browser/renameWidget.ts
5220 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';47import { HoverStyle } from '../../../../base/browser/ui/hover/hover.js';4849/** for debugging */50const _sticky = false51// || Boolean("true") // done "weirdly" so that a lint warning prevents you from pushing this52;535455export const CONTEXT_RENAME_INPUT_VISIBLE = new RawContextKey<boolean>('renameInputVisible', false, nls.localize('renameInputVisible', "Whether the rename input widget is visible"));56export const CONTEXT_RENAME_INPUT_FOCUSED = new RawContextKey<boolean>('renameInputFocused', false, nls.localize('renameInputFocused', "Whether the rename input widget is focused"));5758/**59* "Source" of the new name:60* - 'inputField' - user entered the new name61* - 'renameSuggestion' - user picked from rename suggestions62* - 'userEditedRenameSuggestion' - user _likely_ edited a rename suggestion ("likely" because when input started being edited, a rename suggestion had focus)63*/64export type NewNameSource =65| { k: 'inputField' }66| { k: 'renameSuggestion' }67| { k: 'userEditedRenameSuggestion' };6869/**70* Various statistics regarding rename input field71*/72export type RenameWidgetStats = {73nRenameSuggestions: number;74source: NewNameSource;75timeBeforeFirstInputFieldEdit: number | undefined;76nRenameSuggestionsInvocations: number;77hadAutomaticRenameSuggestionsInvocation: boolean;78};7980export type RenameWidgetResult = {81/**82* The new name to be used83*/84newName: string;85wantsPreview?: boolean;86stats: RenameWidgetStats;87};8889interface IRenameWidget {90/**91* @returns a `boolean` standing for `shouldFocusEditor`, if user didn't pick a new name, or a {@link RenameWidgetResult}92*/93getInput(94where: IRange,95currentName: string,96supportPreview: boolean,97requestRenameSuggestions: (triggerKind: NewSymbolNameTriggerKind, cts: CancellationToken) => ProviderResult<NewSymbolName[]>[],98cts: CancellationTokenSource99): Promise<RenameWidgetResult | boolean>;100101acceptInput(wantsPreview: boolean): void;102cancelInput(focusEditor: boolean, caller: string): void;103104focusNextRenameSuggestion(): void;105focusPreviousRenameSuggestion(): void;106}107108export class RenameWidget implements IRenameWidget, IContentWidget, IDisposable {109110// implement IContentWidget111readonly allowEditorOverflow: boolean = true;112113// UI state114115private _domNode?: HTMLElement;116private _inputWithButton: InputWithButton;117private _renameCandidateListView?: RenameCandidateListView;118private _label?: HTMLDivElement;119120private _nPxAvailableAbove?: number;121private _nPxAvailableBelow?: number;122123// Model state124125private _position?: Position;126private _currentName?: string;127/** Is true if input field got changes when a rename candidate was focused; otherwise, false */128private _isEditingRenameCandidate: boolean;129130private readonly _candidates: Set<string>;131132private _visible?: boolean;133134/** must be reset at session start */135private _beforeFirstInputFieldEditSW: StopWatch;136137/**138* Milliseconds before user edits the input field for the first time139* @remarks must be set once per session140*/141private _timeBeforeFirstInputFieldEdit: number | undefined;142143private _nRenameSuggestionsInvocations: number;144145private _hadAutomaticRenameSuggestionsInvocation: boolean;146147private _renameCandidateProvidersCts: CancellationTokenSource | undefined;148private _renameCts: CancellationTokenSource | undefined;149150private readonly _visibleContextKey: IContextKey<boolean>;151private readonly _disposables = new DisposableStore();152153constructor(154private readonly _editor: ICodeEditor,155private 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) {161this._visibleContextKey = CONTEXT_RENAME_INPUT_VISIBLE.bindTo(contextKeyService);162163this._isEditingRenameCandidate = false;164165this._nRenameSuggestionsInvocations = 0;166167this._hadAutomaticRenameSuggestionsInvocation = false;168169this._candidates = new Set();170171this._beforeFirstInputFieldEditSW = new StopWatch();172173this._inputWithButton = new InputWithButton();174this._disposables.add(this._inputWithButton);175176this._editor.addContentWidget(this);177178this._disposables.add(this._editor.onDidChangeConfiguration(e => {179if (e.hasChanged(EditorOption.fontInfo)) {180this._updateFont();181}182}));183184this._disposables.add(_themeService.onDidColorThemeChange(this._updateStyles, this));185}186187dispose(): void {188this._disposables.dispose();189this._editor.removeContentWidget(this);190}191192getId(): string {193return '__renameInputWidget';194}195196getDomNode(): HTMLElement {197if (!this._domNode) {198this._domNode = document.createElement('div');199this._domNode.className = 'monaco-editor rename-box';200201this._domNode.appendChild(this._inputWithButton.domNode);202203this._renameCandidateListView = this._disposables.add(204new RenameCandidateListView(this._domNode, {205fontInfo: this._editor.getOption(EditorOption.fontInfo),206onFocusChange: (newSymbolName: string) => {207this._inputWithButton.input.value = newSymbolName;208this._isEditingRenameCandidate = false; // @ulugbekna: reset209},210onSelectionChange: () => {211this._isEditingRenameCandidate = false; // @ulugbekna: because user picked a rename suggestion212this.acceptInput(false); // we don't allow preview with mouse click for now213}214})215);216217this._disposables.add(218this._inputWithButton.onDidInputChange(() => {219if (this._renameCandidateListView?.focusedCandidate !== undefined) {220this._isEditingRenameCandidate = true;221}222this._timeBeforeFirstInputFieldEdit ??= this._beforeFirstInputFieldEditSW.elapsed();223if (this._renameCandidateProvidersCts?.token.isCancellationRequested === false) {224this._renameCandidateProvidersCts.cancel();225}226this._renameCandidateListView?.clearFocus();227})228);229230this._label = document.createElement('div');231this._label.className = 'rename-label';232this._domNode.appendChild(this._label);233234this._updateFont();235this._updateStyles(this._themeService.getColorTheme());236}237return this._domNode;238}239240private _updateStyles(theme: IColorTheme): void {241if (!this._domNode) {242return;243}244245const widgetShadowColor = theme.getColor(widgetShadow);246const widgetBorderColor = theme.getColor(widgetBorder);247this._domNode.style.backgroundColor = String(theme.getColor(editorWidgetBackground) ?? '');248this._domNode.style.boxShadow = widgetShadowColor ? ` 0 0 8px 2px ${widgetShadowColor}` : '';249this._domNode.style.border = widgetBorderColor ? `1px solid ${widgetBorderColor}` : '';250this._domNode.style.color = String(theme.getColor(inputForeground) ?? '');251252const border = theme.getColor(inputBorder);253254this._inputWithButton.domNode.style.backgroundColor = String(theme.getColor(inputBackground) ?? '');255this._inputWithButton.input.style.backgroundColor = String(theme.getColor(inputBackground) ?? '');256this._inputWithButton.domNode.style.borderWidth = border ? '1px' : '0px';257this._inputWithButton.domNode.style.borderStyle = border ? 'solid' : 'none';258this._inputWithButton.domNode.style.borderColor = border?.toString() ?? 'none';259}260261private _updateFont(): void {262if (this._domNode === undefined) {263return;264}265assertType(this._label !== undefined, 'RenameWidget#_updateFont: _label must not be undefined given _domNode is defined');266267this._editor.applyFontInfo(this._inputWithButton.input);268269const fontInfo = this._editor.getOption(EditorOption.fontInfo);270this._label.style.fontSize = `${this._computeLabelFontSize(fontInfo.fontSize)}px`;271}272273private _computeLabelFontSize(editorFontSize: number) {274return editorFontSize * 0.8;275}276277getPosition(): IContentWidgetPosition | null {278if (!this._visible) {279return null;280}281282if (!this._editor.hasModel() || // @ulugbekna: shouldn't happen283!this._editor.getDomNode() // @ulugbekna: can happen during tests based on suggestWidget's similar predicate check284) {285return null;286}287288const bodyBox = dom.getClientArea(this.getDomNode().ownerDocument.body);289const editorBox = dom.getDomNodePagePosition(this._editor.getDomNode());290291const cursorBoxTop = this._getTopForPosition();292293this._nPxAvailableAbove = cursorBoxTop + editorBox.top;294this._nPxAvailableBelow = bodyBox.height - this._nPxAvailableAbove;295296const lineHeight = this._editor.getOption(EditorOption.lineHeight);297const { totalHeight: candidateViewHeight } = RenameCandidateView.getLayoutInfo({ lineHeight });298299const 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];302303return {304position: this._position!,305preference: positionPreference,306};307}308309beforeRender(): IDimension | null {310const [accept, preview] = this._acceptKeybindings;311this._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());312313this._domNode!.style.minWidth = `200px`; // to prevent from widening when candidates come in314315return null;316}317318afterRender(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/226975320// this._trace('invoking afterRender, position: ', position ? 'not null' : 'null');321if (position === null) {322// cancel rename when input widget isn't rendered anymore323this.cancelInput(true, 'afterRender (because position is null)');324return;325}326327if (!this._editor.hasModel() || // shouldn't happen328!this._editor.getDomNode() // can happen during tests based on suggestWidget's similar predicate check329) {330return;331}332333assertType(this._renameCandidateListView);334assertType(this._nPxAvailableAbove !== undefined);335assertType(this._nPxAvailableBelow !== undefined);336337const inputBoxHeight = dom.getTotalHeight(this._inputWithButton.domNode);338339const labelHeight = dom.getTotalHeight(this._label!);340341let totalHeightAvailable: number;342if (position === ContentWidgetPositionPreference.BELOW) {343totalHeightAvailable = this._nPxAvailableBelow;344} else {345totalHeightAvailable = this._nPxAvailableAbove;346}347348this._renameCandidateListView.layout({349height: totalHeightAvailable - labelHeight - inputBoxHeight,350width: dom.getTotalWidth(this._inputWithButton.domNode),351});352}353354355private _currentAcceptInput?: (wantsPreview: boolean) => void;356private _currentCancelInput?: (focusEditor: boolean) => void;357private _requestRenameCandidatesOnce?: (triggerKind: NewSymbolNameTriggerKind, cts: CancellationToken) => ProviderResult<NewSymbolName[]>[];358359acceptInput(wantsPreview: boolean): void {360this._trace(`invoking acceptInput`);361this._currentAcceptInput?.(wantsPreview);362}363364cancelInput(focusEditor: boolean, caller: string): void {365// this._trace(`invoking cancelInput, caller: ${caller}, _currentCancelInput: ${this._currentAcceptInput ? 'not undefined' : 'undefined'}`);366this._currentCancelInput?.(focusEditor);367}368369focusNextRenameSuggestion() {370if (!this._renameCandidateListView?.focusNext()) {371this._inputWithButton.input.value = this._currentName!;372}373}374375focusPreviousRenameSuggestion() { // TODO@ulugbekna: this and focusNext should set the original name if no candidate is focused376if (!this._renameCandidateListView?.focusPrevious()) {377this._inputWithButton.input.value = this._currentName!;378}379}380381/**382* @param requestRenameCandidates is `undefined` when there are no rename suggestion providers383*/384getInput(385where: IRange,386currentName: string,387supportPreview: boolean,388requestRenameCandidates: undefined | ((triggerKind: NewSymbolNameTriggerKind, cts: CancellationToken) => ProviderResult<NewSymbolName[]>[]),389cts: CancellationTokenSource390): Promise<RenameWidgetResult | boolean> {391392const { start: selectionStart, end: selectionEnd } = this._getSelection(where, currentName);393394this._renameCts = cts;395396const disposeOnDone = new DisposableStore();397398this._nRenameSuggestionsInvocations = 0;399400this._hadAutomaticRenameSuggestionsInvocation = false;401402if (requestRenameCandidates === undefined) {403this._inputWithButton.button.style.display = 'none';404} else {405this._inputWithButton.button.style.display = 'flex';406407this._requestRenameCandidatesOnce = requestRenameCandidates;408409this._requestRenameCandidates(currentName, false);410411disposeOnDone.add(dom.addDisposableListener(412this._inputWithButton.button,413'click',414() => this._requestRenameCandidates(currentName, true)415));416disposeOnDone.add(dom.addDisposableListener(417this._inputWithButton.button,418dom.EventType.KEY_DOWN,419(e) => {420const keyEvent = new StandardKeyboardEvent(e);421422if (keyEvent.equals(KeyCode.Enter) || keyEvent.equals(KeyCode.Space)) {423keyEvent.stopPropagation();424keyEvent.preventDefault();425this._requestRenameCandidates(currentName, true);426}427}428));429}430431this._isEditingRenameCandidate = false;432433this._domNode!.classList.toggle('preview', supportPreview);434435this._position = new Position(where.startLineNumber, where.startColumn);436this._currentName = currentName;437438this._inputWithButton.input.value = currentName;439this._inputWithButton.input.setAttribute('selectionStart', selectionStart.toString());440this._inputWithButton.input.setAttribute('selectionEnd', selectionEnd.toString());441this._inputWithButton.input.size = Math.max((where.endColumn - where.startColumn) * 1.1, 20); // determines width442443this._beforeFirstInputFieldEditSW.reset();444445446disposeOnDone.add(toDisposable(() => {447this._renameCts = undefined;448cts.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 call450disposeOnDone.add(toDisposable(() => {451if (this._renameCandidateProvidersCts !== undefined) {452this._renameCandidateProvidersCts.dispose(true);453this._renameCandidateProvidersCts = undefined;454}455}));456457disposeOnDone.add(toDisposable(() => this._candidates.clear()));458459const inputResult = new DeferredPromise<RenameWidgetResult | boolean>();460461inputResult.p.finally(() => {462disposeOnDone.dispose();463this._hide();464});465466this._currentCancelInput = (focusEditor) => {467this._trace('invoking _currentCancelInput');468this._currentAcceptInput = undefined;469this._currentCancelInput = undefined;470// fixme session cleanup471this._renameCandidateListView?.clearCandidates();472inputResult.complete(focusEditor);473return true;474};475476this._currentAcceptInput = (wantsPreview) => {477this._trace('invoking _currentAcceptInput');478assertType(this._renameCandidateListView !== undefined);479480const nRenameSuggestions = this._renameCandidateListView.nCandidates;481482let newName: string;483let source: NewNameSource;484const focusedCandidate = this._renameCandidateListView.focusedCandidate;485if (focusedCandidate !== undefined) {486this._trace('using new name from renameSuggestion');487newName = focusedCandidate;488source = { k: 'renameSuggestion' };489} else {490this._trace('using new name from inputField');491newName = this._inputWithButton.input.value;492source = this._isEditingRenameCandidate ? { k: 'userEditedRenameSuggestion' } : { k: 'inputField' };493}494495if (newName === currentName || newName.trim().length === 0 /* is just whitespace */) {496this.cancelInput(true, '_currentAcceptInput (because newName === value || newName.trim().length === 0)');497return;498}499500this._currentAcceptInput = undefined;501this._currentCancelInput = undefined;502this._renameCandidateListView.clearCandidates();503// fixme session cleanup504505inputResult.complete({506newName,507wantsPreview: supportPreview && wantsPreview,508stats: {509source,510nRenameSuggestions,511timeBeforeFirstInputFieldEdit: this._timeBeforeFirstInputFieldEdit,512nRenameSuggestionsInvocations: this._nRenameSuggestionsInvocations,513hadAutomaticRenameSuggestionsInvocation: this._hadAutomaticRenameSuggestionsInvocation,514}515});516};517518disposeOnDone.add(cts.token.onCancellationRequested(() => this.cancelInput(true, 'cts.token.onCancellationRequested')));519if (!_sticky) {520disposeOnDone.add(this._editor.onDidBlurEditorWidget(() => this.cancelInput(!this._domNode?.ownerDocument.hasFocus(), 'editor.onDidBlurEditorWidget')));521}522523this._show();524525return inputResult.p;526}527528private _requestRenameCandidates(currentName: string, isManuallyTriggered: boolean) {529if (this._requestRenameCandidatesOnce === undefined) {530return;531}532if (this._renameCandidateProvidersCts !== undefined) {533this._renameCandidateProvidersCts.dispose(true);534}535536assertType(this._renameCts);537538if (this._inputWithButton.buttonState !== 'stop') {539540this._renameCandidateProvidersCts = new CancellationTokenSource();541542const triggerKind = isManuallyTriggered ? NewSymbolNameTriggerKind.Invoke : NewSymbolNameTriggerKind.Automatic;543const candidates = this._requestRenameCandidatesOnce(triggerKind, this._renameCandidateProvidersCts.token);544545if (candidates.length === 0) {546this._inputWithButton.setSparkleButton();547return;548}549550if (!isManuallyTriggered) {551this._hadAutomaticRenameSuggestionsInvocation = true;552}553554this._nRenameSuggestionsInvocations += 1;555556this._inputWithButton.setStopButton();557558this._updateRenameCandidates(candidates, currentName, this._renameCts.token);559}560}561562/**563* This allows selecting only part of the symbol name in the input field based on the selection in the editor564*/565private _getSelection(where: IRange, currentName: string): { start: number; end: number } {566assertType(this._editor.hasModel());567568const selection = this._editor.getSelection();569let start = 0;570let end = currentName.length;571572if (!Range.isEmpty(selection) && !Range.spansMultipleLines(selection) && Range.containsRange(where, selection)) {573start = Math.max(0, selection.startColumn - where.startColumn);574end = Math.min(where.endColumn, selection.endColumn) - where.startColumn;575}576577return { start, end };578}579580private _show(): void {581this._trace('invoking _show');582this._editor.revealLineInCenterIfOutsideViewport(this._position!.lineNumber, ScrollType.Smooth);583this._visible = true;584this._visibleContextKey.set(true);585this._editor.layoutContentWidget(this);586587// TODO@ulugbekna: could this be simply run in `afterRender`?588setTimeout(() => {589this._inputWithButton.input.focus();590this._inputWithButton.input.setSelectionRange(591parseInt(this._inputWithButton.input.getAttribute('selectionStart')!),592parseInt(this._inputWithButton.input.getAttribute('selectionEnd')!)593);594}, 100);595}596597private async _updateRenameCandidates(candidates: ProviderResult<NewSymbolName[]>[], currentName: string, token: CancellationToken) {598const trace = (...args: unknown[]) => this._trace('_updateRenameCandidates', ...args);599600trace('start');601const namesListResults = await raceCancellation(Promise.allSettled(candidates), token);602603this._inputWithButton.setSparkleButton();604605if (namesListResults === undefined) {606trace('returning early - received updateRenameCandidates results - undefined');607return;608}609610const newNames = namesListResults.flatMap(namesListResult =>611namesListResult.status === 'fulfilled' && isDefined(namesListResult.value)612? namesListResult.value613: []614);615trace(`received updateRenameCandidates results - total (unfiltered) ${newNames.length} candidates.`);616617// deduplicate and filter out the current value618619const distinctNames = arrays.distinct(newNames, v => v.newSymbolName);620trace(`distinct candidates - ${distinctNames.length} candidates.`);621622const validDistinctNames = distinctNames.filter(({ newSymbolName }) => newSymbolName.trim().length > 0 && newSymbolName !== this._inputWithButton.input.value && newSymbolName !== currentName && !this._candidates.has(newSymbolName));623trace(`valid distinct candidates - ${newNames.length} candidates.`);624625validDistinctNames.forEach(n => this._candidates.add(n.newSymbolName));626627if (validDistinctNames.length < 1) {628trace('returning early - no valid distinct candidates');629return;630}631632// show the candidates633trace('setting candidates');634this._renameCandidateListView!.setCandidates(validDistinctNames);635636// ask editor to re-layout given that the widget is now of a different size after rendering rename candidates637trace('asking editor to re-layout');638this._editor.layoutContentWidget(this);639}640641private _hide(): void {642this._trace('invoked _hide');643this._visible = false;644this._visibleContextKey.reset();645this._editor.layoutContentWidget(this);646}647648private _getTopForPosition(): number {649const visibleRanges = this._editor.getVisibleRanges();650let firstLineInViewport: number;651if (visibleRanges.length > 0) {652firstLineInViewport = visibleRanges[0].startLineNumber;653} else {654this._logService.warn('RenameWidget#_getTopForPosition: this should not happen - visibleRanges is empty');655firstLineInViewport = Math.max(1, this._position!.lineNumber - 5); // @ulugbekna: fallback to current line minus 5656}657return this._editor.getTopForLineNumber(this._position!.lineNumber) - this._editor.getTopForLineNumber(firstLineInViewport);658}659660private _trace(...args: unknown[]) {661this._logService.trace('RenameWidget', ...args);662}663}664665class RenameCandidateListView {666667/** Parent node of the list widget; needed to control # of list elements visible */668private readonly _listContainer: HTMLDivElement;669private readonly _listWidget: List<NewSymbolName>;670671private _lineHeight: number;672private _availableHeight: number;673private _minimumWidth: number;674private _typicalHalfwidthCharacterWidth: number;675676private readonly _disposables: DisposableStore;677678// FIXME@ulugbekna: rewrite using event emitters679constructor(parent: HTMLElement, opts: { fontInfo: FontInfo; onFocusChange: (newSymbolName: string) => void; onSelectionChange: () => void }) {680681this._disposables = new DisposableStore();682683this._availableHeight = 0;684this._minimumWidth = 0;685686this._lineHeight = opts.fontInfo.lineHeight;687this._typicalHalfwidthCharacterWidth = opts.fontInfo.typicalHalfwidthCharacterWidth;688689this._listContainer = document.createElement('div');690this._listContainer.className = 'rename-box rename-candidate-list-container';691parent.appendChild(this._listContainer);692693this._listWidget = RenameCandidateListView._createListWidget(this._listContainer, this._candidateViewHeight, opts.fontInfo);694695this._disposables.add(this._listWidget.onDidChangeFocus(696e => {697if (e.elements.length === 1) {698opts.onFocusChange(e.elements[0].newSymbolName);699}700},701this._disposables702));703704this._disposables.add(this._listWidget.onDidChangeSelection(705e => {706if (e.elements.length === 1) {707opts.onSelectionChange();708}709},710this._disposables711));712713this._disposables.add(714this._listWidget.onDidBlur(e => { // @ulugbekna: because list widget otherwise remembers last focused element and returns it as focused element715this._listWidget.setFocus([]);716})717);718719this._listWidget.style(getListStyles({720listInactiveFocusForeground: quickInputListFocusForeground,721listInactiveFocusBackground: quickInputListFocusBackground,722}));723}724725dispose() {726this._listWidget.dispose();727this._disposables.dispose();728}729730// height - max height allowed by parent element731public layout({ height, width }: { height: number; width: number }): void {732this._availableHeight = height;733this._minimumWidth = width;734}735736public setCandidates(candidates: NewSymbolName[]): void {737738// insert candidates into list widget739this._listWidget.splice(0, 0, candidates);740741// adjust list widget layout742const height = this._pickListHeight(this._listWidget.length);743const width = this._pickListWidth(candidates);744745this._listWidget.layout(height, width);746747// adjust list container layout748this._listContainer.style.height = `${height}px`;749this._listContainer.style.width = `${width}px`;750751aria.status(nls.localize('renameSuggestionsReceivedAria', "Received {0} rename suggestions", candidates.length));752}753754public clearCandidates(): void {755this._listContainer.style.height = '0px';756this._listContainer.style.width = '0px';757this._listWidget.splice(0, this._listWidget.length, []);758}759760public get nCandidates() {761return this._listWidget.length;762}763764public get focusedCandidate(): string | undefined {765if (this._listWidget.length === 0) {766return;767}768const selectedElement = this._listWidget.getSelectedElements()[0];769if (selectedElement !== undefined) {770return selectedElement.newSymbolName;771}772const focusedElement = this._listWidget.getFocusedElements()[0];773if (focusedElement !== undefined) {774return focusedElement.newSymbolName;775}776return;777}778779public focusNext(): boolean {780if (this._listWidget.length === 0) {781return false;782}783const focusedIxs = this._listWidget.getFocus();784if (focusedIxs.length === 0) {785this._listWidget.focusFirst();786this._listWidget.reveal(0);787return true;788} else {789if (focusedIxs[0] === this._listWidget.length - 1) {790this._listWidget.setFocus([]);791this._listWidget.reveal(0); // @ulugbekna: without this, it seems like focused element is obstructed792return false;793} else {794this._listWidget.focusNext();795const focused = this._listWidget.getFocus()[0];796this._listWidget.reveal(focused);797return true;798}799}800}801802/**803* @returns true if focus is moved to previous element804*/805public focusPrevious(): boolean {806if (this._listWidget.length === 0) {807return false;808}809const focusedIxs = this._listWidget.getFocus();810if (focusedIxs.length === 0) {811this._listWidget.focusLast();812const focused = this._listWidget.getFocus()[0];813this._listWidget.reveal(focused);814return true;815} else {816if (focusedIxs[0] === 0) {817this._listWidget.setFocus([]);818return false;819} else {820this._listWidget.focusPrevious();821const focused = this._listWidget.getFocus()[0];822this._listWidget.reveal(focused);823return true;824}825}826}827828public clearFocus(): void {829this._listWidget.setFocus([]);830}831832private get _candidateViewHeight(): number {833const { totalHeight } = RenameCandidateView.getLayoutInfo({ lineHeight: this._lineHeight });834return totalHeight;835}836837private _pickListHeight(nCandidates: number) {838const heightToFitAllCandidates = this._candidateViewHeight * nCandidates;839const MAX_N_CANDIDATES = 7; // @ulugbekna: max # of candidates we want to show at once840const height = Math.min(heightToFitAllCandidates, this._availableHeight, this._candidateViewHeight * MAX_N_CANDIDATES);841return height;842}843844private _pickListWidth(candidates: NewSymbolName[]): number {845const longestCandidateWidth = Math.ceil(Math.max(...candidates.map(c => c.newSymbolName.length)) * this._typicalHalfwidthCharacterWidth);846const width = Math.max(847this._minimumWidth,8484 /* padding */ + 16 /* sparkle icon */ + 5 /* margin-left */ + longestCandidateWidth + 10 /* (possibly visible) scrollbar width */ // TODO@ulugbekna: approximate calc - clean this up849);850return width;851}852853private static _createListWidget(container: HTMLElement, candidateViewHeight: number, fontInfo: FontInfo) {854const virtualDelegate = new class implements IListVirtualDelegate<NewSymbolName> {855getTemplateId(element: NewSymbolName): string {856return 'candidate';857}858859getHeight(element: NewSymbolName): number {860return candidateViewHeight;861}862};863864const renderer = new class implements IListRenderer<NewSymbolName, RenameCandidateView> {865readonly templateId = 'candidate';866867renderTemplate(container: HTMLElement): RenameCandidateView {868return new RenameCandidateView(container, fontInfo);869}870871renderElement(candidate: NewSymbolName, index: number, templateData: RenameCandidateView): void {872templateData.populate(candidate);873}874875disposeTemplate(templateData: RenameCandidateView): void {876templateData.dispose();877}878};879880return new List(881'NewSymbolNameCandidates',882container,883virtualDelegate,884[renderer],885{886keyboardSupport: false, // @ulugbekna: because we handle keyboard events through proper commands & keybinding service, see `rename.ts`887mouseSupport: true,888multipleSelectionSupport: false,889}890);891}892}893894class InputWithButton implements IDisposable {895896private _buttonState: 'sparkle' | 'stop' | undefined;897898private _domNode: HTMLDivElement | undefined;899private _inputNode: HTMLInputElement | undefined;900private _buttonNode: HTMLElement | undefined;901private _buttonHoverContent: string = '';902private _buttonGenHoverText: string | undefined;903private _buttonCancelHoverText: string | undefined;904private _sparkleIcon: HTMLElement | undefined;905private _stopIcon: HTMLElement | undefined;906907private readonly _onDidInputChange = new Emitter<void>();908public readonly onDidInputChange = this._onDidInputChange.event;909910private readonly _disposables = new DisposableStore();911912get domNode() {913if (!this._domNode) {914915this._domNode = document.createElement('div');916this._domNode.className = 'rename-input-with-button';917this._domNode.style.display = 'flex';918this._domNode.style.flexDirection = 'row';919this._domNode.style.alignItems = 'center';920921this._inputNode = document.createElement('input');922this._inputNode.className = 'rename-input';923this._inputNode.type = 'text';924this._inputNode.style.border = 'none';925this._inputNode.setAttribute('aria-label', nls.localize('renameAriaLabel', "Rename input. Type new name and press Enter to commit."));926927this._domNode.appendChild(this._inputNode);928929this._buttonNode = document.createElement('div');930this._buttonNode.className = 'rename-suggestions-button';931this._buttonNode.setAttribute('tabindex', '0');932933this._buttonGenHoverText = nls.localize('generateRenameSuggestionsButton', "Generate New Name Suggestions");934this._buttonCancelHoverText = nls.localize('cancelRenameSuggestionsButton', "Cancel");935this._buttonHoverContent = this._buttonGenHoverText;936this._disposables.add(getBaseLayerHoverDelegate().setupDelayedHover(this._buttonNode, () => ({937content: this._buttonHoverContent,938style: HoverStyle.Pointer,939})));940941this._domNode.appendChild(this._buttonNode);942943// notify if selection changes to cancel request to rename-suggestion providers944945this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.INPUT, () => this._onDidInputChange.fire()));946this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.KEY_DOWN, (e) => {947const keyEvent = new StandardKeyboardEvent(e);948if (keyEvent.keyCode === KeyCode.LeftArrow || keyEvent.keyCode === KeyCode.RightArrow) {949this._onDidInputChange.fire();950}951}));952this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.CLICK, () => this._onDidInputChange.fire()));953954// focus "container" border instead of input box955956this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.FOCUS, () => {957this.domNode.style.outlineWidth = '1px';958this.domNode.style.outlineStyle = 'solid';959this.domNode.style.outlineOffset = '-1px';960this.domNode.style.outlineColor = 'var(--vscode-focusBorder)';961}));962this._disposables.add(dom.addDisposableListener(this.input, dom.EventType.BLUR, () => {963this.domNode.style.outline = 'none';964}));965}966return this._domNode;967}968969get input() {970assertType(this._inputNode);971return this._inputNode;972}973974get button() {975assertType(this._buttonNode);976return this._buttonNode;977}978979get buttonState() {980return this._buttonState;981}982983setSparkleButton() {984this._buttonState = 'sparkle';985this._sparkleIcon ??= renderIcon(Codicon.sparkle);986dom.clearNode(this.button);987this.button.appendChild(this._sparkleIcon);988this.button.setAttribute('aria-label', 'Generating new name suggestions');989this._buttonHoverContent = this._buttonGenHoverText!;990this.input.focus();991}992993setStopButton() {994this._buttonState = 'stop';995this._stopIcon ??= renderIcon(Codicon.stopCircle);996dom.clearNode(this.button);997this.button.appendChild(this._stopIcon);998this.button.setAttribute('aria-label', 'Cancel generating new name suggestions');999this._buttonHoverContent = this._buttonCancelHoverText!;1000this.input.focus();1001}10021003dispose(): void {1004this._disposables.dispose();1005}1006}10071008class RenameCandidateView {10091010private static _PADDING: number = 2;10111012private readonly _domNode: HTMLElement;1013private readonly _icon: HTMLElement;1014private readonly _label: HTMLElement;10151016constructor(parent: HTMLElement, fontInfo: FontInfo) {10171018this._domNode = document.createElement('div');1019this._domNode.className = 'rename-box rename-candidate';1020this._domNode.style.display = `flex`;1021this._domNode.style.columnGap = `5px`;1022this._domNode.style.alignItems = `center`;1023this._domNode.style.height = `${fontInfo.lineHeight}px`;1024this._domNode.style.padding = `${RenameCandidateView._PADDING}px`;10251026// @ulugbekna: needed to keep space when the `icon.style.display` is set to `none`1027const iconContainer = document.createElement('div');1028iconContainer.style.display = `flex`;1029iconContainer.style.alignItems = `center`;1030iconContainer.style.width = iconContainer.style.height = `${fontInfo.lineHeight * 0.8}px`;1031this._domNode.appendChild(iconContainer);10321033this._icon = renderIcon(Codicon.sparkle);1034this._icon.style.display = `none`;1035iconContainer.appendChild(this._icon);10361037this._label = document.createElement('div');1038domFontInfo.applyFontInfo(this._label, fontInfo);1039this._domNode.appendChild(this._label);10401041parent.appendChild(this._domNode);1042}10431044public populate(value: NewSymbolName) {1045this._updateIcon(value);1046this._updateLabel(value);1047}10481049private _updateIcon(value: NewSymbolName) {1050const isAIGenerated = !!value.tags?.includes(NewSymbolNameTag.AIGenerated);1051this._icon.style.display = isAIGenerated ? 'inherit' : 'none';1052}10531054private _updateLabel(value: NewSymbolName) {1055this._label.innerText = value.newSymbolName;1056}10571058public static getLayoutInfo({ lineHeight }: { lineHeight: number }): { totalHeight: number } {1059const totalHeight = lineHeight + RenameCandidateView._PADDING * 2 /* top & bottom padding */;1060return { totalHeight };1061}10621063public dispose() {1064}1065}106610671068